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:
- JWT signature valid against current/previous Ed25519 keys
exp > now,nbf ≤ now,iat ≤ nowaudmatches the request'sHostheaderissresolves to a partner whose status isactive- The request's
Origin(orReferer) is in the partner's allowed-origins list ck.partner.project_idbelongs to the partnerck.scope.template_id(if present) belongs to the partner's project AND the template is not deletedck.catalog_refis owned by this sessionjtinot yet seen → mark seen forexpwindow
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:
scope.template_idorscope.template_external_idmust be set — the form needs a template to render. Sessions without one resolve the form route totemplate_not_resolved(404).- 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 andtemplate_unpublishedon the embed error page. permissions.submit_formis required to callPOST /v1/embed/form-submit/:sessionId. Sessions withsubmit_form: falsestill load the form UI (useful as a preview affordance) but submission returnspermission_denied(403).ck.form.prefillkeys are validated against the resolved variable schema at submit time (invalid_input_dataif a key isn't in the manifest). Unknown keys inprefillare silently dropped at session mint, not at submit, so a stale partner integration can't accidentally inject data the template doesn't accept.- The other modes (
'edit' | 'create' | 'view') cannot call the form-submit endpoint — they receivewrong_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