Craftkitdocs

JWT spec

Session token shape, signing, rotation, and verification.

02 — JWT & Session Specification

This document is the authoritative reference for the embed session token format.

Signing

Algorithm EdDSA (Ed25519) — small, fast, modern. Asymmetric so partners can verify tokens publicly without secret round-trips
Key rotation kid header points to current key; previous keys honored for 24h overlap
TTL 5 minutes for the session token; 24h for the optional context token
Transport URL query param on initial load only (?session_token=…); subsequent renewals via postMessage only — never re-write the URL
Storage in iframe In-memory only. Never localStorage / sessionStorage / cookies

Claims schema

{
  // Standard JWT claims
  "iss": "ck_pk_live_aB3xQ7…",          // partner's publishable key (issuer)
  "aud": "embed.craftkit.dev",           // expected iframe host
  "sub": "session_01HVRX...",            // session id (audit trail key)
  "iat": 1714657200,
  "exp": 1714657500,                     // iat + 300s
  "nbf": 1714657200,
  "jti": "ck_sess_…",                    // single-use enforcement on renewals

  // Craftkit-specific claims (under "ck" namespace)
  "ck": {
    "v": 1,                              // schema version

    "partner": {
      "id": "ck_partner_01HVR…",         // resolved from publishable key
      "project_id": "ck_proj_01HVR…"     // which project hosts the templates
    },

    "tenant": {
      "external_id": "partner-org-12345",
      "display_name": "Acme Charters Ltd"
    },

    "actor": {
      "external_id": "partner-user-67890",
      "display_name": "Jane Doe",
      "email": "jane@acme.com",          // optional; audit only
      "avatar_url": "https://…"          // optional
    },

    "scope": {
      "mode": "edit",                    // "edit" | "create" | "view" | "fill"
      "template_id": "ck_tpl_01HVR…",    // null when mode=create; required when mode=fill
      "template_external_id": "partner-template-9999"
    },

    "permissions": {
      "publish": true,
      "save_draft": true,
      "delete": false,
      "create_custom_variables": false,  // false = catalog-locked
      "change_page_settings": true,
      "rename": false,
      "view_version_history": true,
      "rollback_version": false,
      "submit_form": true,               // mode=fill: required to POST /v1/embed/form-submit
      "save_form_draft": false           // mode=fill: placeholder, phase 3
    },

    "form": {                            // optional; only consulted when mode=fill
      "prefill": { "customer.name": "Ada Lovelace" },
      "show_preview": true,              // side-by-side live preview pane
      "show_document_after_submit": true,// false = blank/close iframe after submit
      "redirect_url": null               // optional partner "back" URL
    },

    "catalog_ref": "cat_01HVR…",         // pointer to a stored catalog

    "branding": {
      "logo_url": "https://acme.com/logo.svg",
      "primary_color": "#2563EB",
      "font_url": null,
      "locale": "en-GB",
      "ui": {
        "show_top_bar": false,
        "show_template_list": false,
        "show_render_history": false,
        "show_api_keys_link": false,
        "show_publish_button": true,
        "show_save_draft_button": true,
        "show_close_button": true
      }
    },

    "callbacks": {
      "on_published": "https://saas.com/api/craftkit/events",
      "on_close_url": "https://saas.com/back-to-template-list"
    },

    "limits": {
      "max_publishes": 5,
      "max_save_drafts": 50,
      "max_uploads_bytes": 5242880
    },

    "renew_token": "rt_01HVR…"           // opaque; for /v1/embed/sessions/refresh
  }
}

Why split catalog_ref from inline catalog

Variable catalogs can be large (typical partner has 100–500 fields). Encoding that in a JWT URL would explode past most browser URL limits.

So the partner POSTs the catalog once when minting the session; Craftkit stores it server-side; the JWT carries only the reference. The embed page fetches it with Authorization: Bearer <session_token> on load.

Validation rules (server-side)

Every embed page render must pass ALL of these:

  1. JWT signature valid against current/previous Ed25519 keys
  2. exp > now, nbf ≤ now, iat ≤ now
  3. aud matches the request's Host header
  4. iss resolves to a partner whose status is active
  5. The request's Origin (or Referer) is in the partner's allowed-origins list
  6. ck.partner.project_id belongs to the partner
  7. ck.scope.template_id (if present) belongs to the partner's project AND the template is not deleted
  8. ck.catalog_ref is owned by this session
  9. jti not yet seen → mark seen for exp window

If any fail → render /embed/error?code=session_invalid with NO leak of which check failed.

Form-mode requirements (scope.mode === 'fill')

Form-fill sessions have a few extra invariants on top of the generic validation list above:

  1. scope.template_id or scope.template_external_id must be set — the form needs a template to render. Sessions without one resolve the form route to template_not_resolved (404).
  2. The resolved template must have a published version. Drafts are not fillable. A template with no published version surfaces as unpublished_template (404) on the API and template_unpublished on the embed error page.
  3. permissions.submit_form is required to call POST /v1/embed/form-submit/:sessionId. Sessions with submit_form: false still load the form UI (useful as a preview affordance) but submission returns permission_denied (403).
  4. ck.form.prefill keys are validated against the resolved variable schema at submit time (invalid_input_data if a key isn't in the manifest). Unknown keys in prefill are silently dropped at session mint, not at submit, so a stale partner integration can't accidentally inject data the template doesn't accept.
  5. The other modes ('edit' | 'create' | 'view') cannot call the form-submit endpoint — they receive wrong_mode (403).

The ck.form block is optional; absent claims behave as if prefill={}, showPreview=false, showDocumentAfterSubmit=true, redirectUrl=null.

Renewal flow

T-30s before expiry:
  iframe → parent : { type: 'craftkit.session.expiring', seconds: 30 }
  parent → its backend : POST /api/craftkit/refresh-session { renew_token }
  backend → Craftkit  : POST /v1/embed/sessions/refresh
                        { renew_token, ck_live_* }
                    ←   { session_token: "eyJ…", expires_at: ... }
  parent → iframe   : { type: 'craftkit.token.refresh', token: 'eyJ…' }
  iframe acknowledges and replaces in-memory token

If renewal fails (revoked / partner deleted / actor disabled):
  parent receives 401 → calls iframe `craftkit.session.terminate`
  iframe shows "Session expired — please reopen this template"

renew_token is single-use (rotates each refresh). Limits replay attacks.


Last revised: 2026-05-02