Kwisple
Browse the docs

Apps: custom modules per client

Security model

How the app session is signed, delivered and renewed, what your site must verify on every request, what never to do, and framing rules including white-label dashboard hosts.

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 valueKey
App sessionkwisple-app-session:<secret>
Staging ticketkwisple-staging-ticket:<secret>
Staging sessionkwisple-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 exp is reached, and rejects a token whose iat lies more than 60 seconds in the future (clock skew is tolerated, forged future tokens are not).
  • With app given, 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:

  1. Your page calls connectApp({ dashboardOrigin }). It posts kwisple:app:ready to 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.
  2. The dashboard's frame component (AppFrame) accepts kwisple:app:ready only when the message's source is its own frame and its origin is the site's origin. It answers with { type: "kwisple:app:session", token }.
  3. 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.
  4. connectApp accepts a session only when e.origin is one of your dashboardOrigin values and e.source is window.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; connectApp swaps it in and onSession listeners 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 appSessionFrom only checks the signature, the expiry and the app. A token signed before the change stays valid until its exp, 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:

  1. Read the bearer and verify it with appSessionFrom(request, { secret, app }) or appPlatform(request, { secret, app, dashboardOrigin }). On null, answer 401 and do nothing else.
  2. Pass the right app handle. Each route should accept one app's sessions only. Typing the handle as a union ("boekingen" | "golfgenerator") catches mistakes at compile time.
  3. Check the level for the action with appLevelAtLeast(session.level, "edit") (or whatever the action needs) before any work that is not pure reading.
  4. Scope everything to the session. Use session.workspace.id and session.site for 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.
  5. 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.
  6. Answer with cache-control: no-store for 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. Log session.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.fetch attaches 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().ready rejects, 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.level yourself.

Content-Security-Policy and framing

The dashboard frames your page cross-origin, so the site has to allow it:

  • frame-ancestors on the app's pages must include the dashboard's origin (KWISPLE_DASHBOARD_ORIGIN), for example Content-Security-Policy: frame-ancestors 'self' https://app.example. RANSON.N sets it on every response in next.config.ts; see Framing requirements. Without it the frame stays blank.
  • No X-Frame-Options on those pages. Browsers that see DENY or SAMEORIGIN block the frame even when frame-ancestors allows it.
  • Nested frames: frame-ancestors is 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 (plus http: 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-ancestors narrow: '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:

  1. In frame-ancestors: list every dashboard origin the client's people use.
  2. In connectApp: pass a list. dashboardOrigin accepts a string or an array; the ready request 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

  1. Every app route verifies the session (appSessionFrom / appPlatform) with the right handle, and answers 401 on null.
  2. Every action beyond reading checks appLevelAtLeast on the server.
  3. No cookies, no stored tokens, no tokens in URLs or logs, no secret in the browser.
  4. frame-ancestors lists every dashboard origin; no X-Frame-Options; https in production.
  5. connectApp gets every dashboard origin; ready rejecting ends in a message, not a fallback.
  6. Header-less requests use your own short ticket with its own key.
  7. Opened directly in a browser tab, the page says it lives in the dashboard and its API routes answer 401.

Last updated 2026-10-30