An app is a page of the client's site, shown inside the dashboard. Two parties that do not share cookies, origins or a database have to agree on one thing: who is using this page, for which client, with which rights. This page explains how the platform answers that, which guarantees you get, and which ones your site has to provide itself.
In short:
- The dashboard signs a session for one person, one client and one app, with a key only the platform and the site know. It is valid for 15 minutes and renewed while the app is open.
- The session reaches your page over
postMessage, addressed to the site's exact origin. - Your server verifies the session on every request and decides what the person may do.
- When your server acts on platform data, the platform checks the person's live rights again. An app never gets more than the person has right now.
The session token
A session is a string of three dot-separated parts:
app1.<base64url JSON claims>.<base64url HMAC-SHA256>The claims (AppSession in @kwisple/cms) are listed in
Building the page: app, site, workspace,
user, team, level, locale, iat, exp. The dashboard fills them in from the person's
access at the moment of signing (appSession() in apps/dashboard/src/lib/apps.ts).
The signing key
The signature is an HMAC-SHA256 over app1.<claims>. Its key is not the raw secret but
kwisple-app-session:<webhook secret>, where the webhook secret is the site's
Webhook-geheim (your KWISPLE_WEBHOOK_SECRET). The same secret serves other purposes, each
with its own key:
| Signed value | Key |
|---|---|
| App session | kwisple-app-session:<secret> |
| Staging ticket | kwisple-staging-ticket:<secret> |
| Staging session | kwisple-staging-session:<secret> |
Publish webhook (x-kwisple-signature) | the raw secret, over the request body |
Because every use has its own key, one signed value can never be replayed as another. A
publish-webhook signature never verifies as an app session, an app session never passes as a
webhook signature, and a staging ticket opens nothing in an app. Do the same when you sign
values of your own: derive a key per purpose (the generator's ticket uses
ranson-beheer-ticket:<secret>, see Example: a generator tool).
Signing and verifying use Web Crypto only, so verifyAppSession runs in Node, edge runtimes
and browsers. The comparison is constant-time.
Lifetime
- A session lives 15 minutes (
APP_SESSION_SECONDS). - Verification rejects it once
expis reached, and rejects a token whoseiatlies more than 60 seconds in the future (clock skew is tolerated, forged future tokens are not). - With
appgiven, a session for another app of the same site is rejected.
How the session reaches the page
The dashboard renders your page in an <iframe> and knows the site's origin (from the
Site-URL). Only that origin ever receives a session:
- Your page calls
connectApp({ dashboardOrigin }). It postskwisple:app:readyto the parent at each given origin (posting to an origin that does not match the parent is dropped by the browser), right away and then every 500 ms. - The dashboard's frame component (
AppFrame) acceptskwisple:app:readyonly when the message'ssourceis its own frame and itsoriginis the site's origin. It answers with{ type: "kwisple:app:session", token }. - Every session the dashboard posts, on request, once when the frame finishes loading, and at
each renewal, uses
postMessage(…, siteOrigin). If the frame has navigated to another origin in the meantime, the browser drops the message: a session never leaves the site's origin. connectAppaccepts a session only whene.originis one of yourdashboardOriginvalues ande.sourceiswindow.parent. A page that frames your app to fish for its requests gets nothing, and a page that posts a fake session to your app is ignored.
The frame runs with
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
and allow="clipboard-write". allow-same-origin keeps your page on its own origin, with its
own storage and requests; it gives your page no access to the dashboard's page or cookies.
A path declared in defineApp can never move the frame to another site: the dashboard resolves
path against the Site-URL and refuses a result on another origin (the app then shows Deze
app is nog niet bereikbaar).
Renewal and losing access
While the app is open the dashboard renews the session every 10 minutes, five minutes
before it expires. Renewal is a server action that resolves the person's access again
(renewAppSessionAction):
- still allowed: a fresh session is signed (with the level the person has now) and posted;
connectAppswaps it in andonSessionlisteners fire; - no longer allowed (role changed, app removed from the schema, site unlinked, person removed): renewal returns nothing, the dashboard stops and replaces the frame with Deze app is gesloten omdat je sessie afliep of je geen toegang meer hebt. Laad de pagina opnieuw om verder te gaan.
Be precise about what that means for a token that is already out:
- On the apps API the old token stops working at once for anything the person lost: every request re-checks the person's live access, the app being theirs, the permission and the level (Server side).
- On your own server
appSessionFromonly checks the signature, the expiry and the app. A token signed before the change stays valid until itsexp, at most 15 minutes. If your app does something sensitive without the platform (deleting files, writing to another system), either accept that window or confirm through an apps API call first.
Changing the site's webhook secret (Vernieuwen next to Webhook-geheim) invalidates all sessions at once: tokens signed with the old secret no longer verify. Put the new value in the site's environment straight away, or every app request fails.
What your site must verify on every request
Every route that an app page calls, and every route under the app's path that returns data:
- Read the bearer and verify it with
appSessionFrom(request, { secret, app })orappPlatform(request, { secret, app, dashboardOrigin }). Onnull, answer401and do nothing else. - Pass the right
apphandle. Each route should accept one app's sessions only. Typing the handle as a union ("boekingen" | "golfgenerator") catches mistakes at compile time. - Check the level for the action with
appLevelAtLeast(session.level, "edit")(or whatever the action needs) before any work that is not pure reading. - Scope everything to the session. Use
session.workspace.idandsession.sitefor anything you look up or store. A site normally serves one client, but code that is shared across sites must never take the client from the request body or URL. - Re-read state on the server. Look records up again by id (as the booking desk does with
app.submissions) instead of trusting what the browser sends back. - Answer with
cache-control: no-storefor anything personal.
What not to do
- No cookies, no cookie sessions, no CSRF cookies. The page is third-party inside the dashboard; browsers block or partition its cookies. The bearer header is the authentication, and because it is a header, CSRF does not apply.
- Never trust the level in the browser.
connectApp().session()reads claims without verifying them. Use it for "Aangemeld als …" and to hide buttons, never for access. A person can edit anything in their own browser. - Never store the session. No
localStorage,sessionStorage, database column or log line. A token is a 15-minute bearer credential for that person in that app; anyone holding it can call your routes and the apps API as them. Logsession.user.id, never the token. - Never put the token in a URL. URLs end up in logs, history and referrers. When a request
cannot carry a header (an
<iframe src>, a download link), mint your own short ticket from a verified request under its own key, as the generator does. - Never send the token to a third party.
app.fetchattaches the bearer to whatever URL you give it. Use it for your own site's routes only; call other services from your server. - Never expose the webhook secret to the browser (no
NEXT_PUBLIC_variable, no inline config). It signs sessions; with it anyone could mint one. - No unauthenticated fallback. When
connectApp().readyrejects, show that the tool lives in the dashboard and stop. Do not offer a "continue anyway". - No open CORS on app routes. Your page calls same-origin routes; they need no
Access-Control-Allow-Origin. - Do not widen access with the site token. The site's API token has the site's rights, not
the person's. Gate what you do with it by
session.levelyourself.
Content-Security-Policy and framing
The dashboard frames your page cross-origin, so the site has to allow it:
frame-ancestorson the app's pages must include the dashboard's origin (KWISPLE_DASHBOARD_ORIGIN), for exampleContent-Security-Policy: frame-ancestors 'self' https://app.example. RANSON.N sets it on every response innext.config.ts; see Framing requirements. Without it the frame stays blank.- No
X-Frame-Optionson those pages. Browsers that seeDENYorSAMEORIGINblock the frame even whenframe-ancestorsallows it. - Nested frames:
frame-ancestorsis checked against every ancestor. A page framed inside your app page needs the dashboard origin in its policy too. - https in production. The dashboard's own policy allows frames from
https:origins (plushttp:only when the dashboard itself runs on http, which is local development). A site on plain http does not load in a production dashboard. - Keep
frame-ancestorsnarrow:'self'plus the dashboard origin(s). Do not use*; the session handshake is safe against hostile framers, but your page's UI (clickjacking) is not.
The rest of your site's CSP (script-src, connect-src, …) is yours. The app page only needs
to reach its own origin.
White-label dashboard hosts
An agency can serve the dashboard on its own domain instead of the platform's. A client of
that agency may then open the app from https://dashboard.agency.example instead of the
platform dashboard. Both origins frame your page and both must be accepted:
- In
frame-ancestors: list every dashboard origin the client's people use. - In
connectApp: pass a list.dashboardOriginaccepts a string or an array; thereadyrequest is posted to each, and a session is accepted from any of them.
// Both dashboards that can frame this client's apps.
const DASHBOARDS = [process.env.KWISPLE_DASHBOARD_ORIGIN ?? "", "https://dashboard.agency.example"];
const app = connectApp({ dashboardOrigin: DASHBOARDS });kwisple link writes KWISPLE_DASHBOARD_ORIGIN as the dashboard the CLI talked to: the
agency's own host when you logged in there, otherwise the platform dashboard. Only one value
fits in that variable; add the other origin in code as above, in both places.
On the server nothing changes. The session is signed with the site's secret whichever host
signed it, and appPlatform may call the apps API on either origin (dashboardOrigin there is
only where the API is reached).
Staging
Apps are declared by production sites only; a staging deployment never shows up under Apps.
If you test the app's pages on staging by hand, the staging gate must let the app's paths
through (open: ["/beheer", "/api/beheer"] in RANSON.N's stagingGate call), because those
routes check the app session themselves. Opening them is safe for exactly that reason: without
a valid session they answer 401. See Staging.
Checklist
- Every app route verifies the session (
appSessionFrom/appPlatform) with the right handle, and answers401onnull. - Every action beyond reading checks
appLevelAtLeaston the server. - No cookies, no stored tokens, no tokens in URLs or logs, no secret in the browser.
frame-ancestorslists every dashboard origin; noX-Frame-Options; https in production.connectAppgets every dashboard origin;readyrejecting ends in a message, not a fallback.- Header-less requests use your own short ticket with its own key.
- Opened directly in a browser tab, the page says it lives in the dashboard and its API
routes answer
401.