JWT envelope
A JWT: sub-block under any authenticator turns on a self-contained signed JSON Web Token, issued in an HttpOnly cookie on a successful login and validated on every subsequent request. The token replaces the legacy opaque session-id cookie and carries selected user-profile claims (sub, name, db, lang, roles).
The JWT envelope is orthogonal to the authenticator: the credential check stays with whatever Auth: you configured (DB, TextFile, LDAP, OSDB, a custom class…), and the JWT: block just adds the token layer on top. Presence of the Auth/JWT node is what enables it — remove the block and the same authenticator falls back to a plain server-side session cookie.
It is opt-in and additive: an application on Auth: DB / Auth: TextFile / a custom authenticator with no JWT: block is untouched and does not pull in the underlying delphi-jose-jwt library. To use JWT, add Kitto.Auth.JWT to the uses clause of UseKitto.pas (see Opt-in) and add the JOSE source folder to the .dproj unit search path. That unit provides the JOSE-backed engine and registers it at startup; the core stays JOSE-free.
When to use it
- You want stateless authentication that survives multiple instances behind a load balancer.
- You need a verified credential carried on every request without a server-side lookup.
- You want to embed the user profile (display name, environment, language, roles) in the credential itself.
- You plan to integrate external identity providers (OIDC, SAML) — the JWT envelope is the architectural extension point for those (see Extension points).
Opt-in from UseKitto.pas
Add Kitto.Auth.JWT to your application's UseKitto.pas. The unit registers the JOSE-backed JWT engine (via RegisterJWTEngine in its initialization), so the base TKAuthenticator can issue and validate tokens whenever an Auth/JWT block is present. Register a key provider in the same initialization block so all .dpr flavors of your app (Standalone, ISAPI, Desktop, Apache module) share the same signing key without each having to set an environment variable:
unit UseKitto;
interface
uses
Kitto.Html.All
// ...
, Kitto.Auth.DB // your authenticator
, Kitto.Auth.JWT; // JWT engine (JOSE-backed)
implementation
uses
System.SysUtils,
Kitto.Web.JWT,
JOSE.Core.JWA;
initialization
TKJWTSigningKeyRegistry.Instance.RegisterProvider('MyAppName',
function: TKJWTSigningKey
begin
Result.Algorithm := TJOSEAlgorithmId.HS256;
// FOR PRODUCTION: load from a vault, environment variable, or
// platform secret manager (see comments in Kitto.Web.JWT.pas).
Result.PrivateKey := TEncoding.UTF8.GetBytes(GetMyAppSigningKey);
end);
end.The 'MyAppName' argument is matched (case-insensitive) against TKConfig.AppName, so the same provider is used across all .dpr flavors of the app while leaving other JWT-enabled apps in the same process free to register their own key. The registered provider always takes precedence over Auth/JWT/SigningKey in Config.yaml.
Then add the JOSE source folder to the .dproj unit search path:
DCC_UnitSearchPath = ...;..\..\..\Source\ThirdParty\delphi-jose-jwt\Source;$(DCC_UnitSearchPath)If the engine is not linked
If a Config.yaml contains an Auth/JWT block but Kitto.Auth.JWT is missing from UseKitto.pas, no engine is registered and the first token operation raises an explanatory error ("add Kitto.Auth.JWT to your project's UseKitto.pas"). The core deliberately carries no JOSE dependency, so it cannot issue tokens on its own.
Minimal configuration
Auth: DB
ReadUserCommandText: |
select USER_NAME, PASSWORD_HASH from KITTO_USERS
where UPPER(USER_NAME) = UPPER(:USER_NAME)
JWT:
SigningAlgorithm: HS256
SigningKey: env:KX_JWT_KEY # fallback used if no provider registered
Issuer: %APPNAME%
TokenLifetime: 3600If no provider is registered in UseKitto.pas, Auth/JWT/SigningKey is read from the YAML — set the KX_JWT_KEY environment variable (or use file:/path or an inline literal) before starting the server.
Full configuration reference
The authenticator's own keys (credential check, storage, login-form behaviour) stay directly under Auth/, exactly as they are without a token. Only the token envelope keys go under Auth/JWT/.
Auth: DB
# === Authenticator keys (unchanged by the token layer) ===
IsClearPassword: True
DatabaseChoices: FireDAC_MSSQL, FireDAC_PostgreSQL, FireDAC_Firebird
ReadUserCommandText: |
select USER_NAME, PASSWORD_HASH, EMAIL_ADDRESS, FIRST_NAME, LAST_NAME
from KITTO_USERS
where UPPER(USER_NAME) = UPPER(:USER_NAME)
ValidatePassword:
Message: Min 8 with upper+lower+number+special chars
RegEx: ^(?=.*\d)(?=.*[A-Z])(?=.*[a-z])(?=.*[^\w\d\s:])([^\s]){8,16}$
# === JWT envelope (presence of this node turns tokens on) ===
JWT:
# --- Signing ---
# HS256/HS384/HS512: HMAC, no OpenSSL required from Delphi 10 Seattle on.
# RS256/RS384/RS512/ES256/ES384/ES512: asymmetric, deploy OpenSSL DLLs.
SigningAlgorithm: HS256
# Key resolution prefixes:
# env:VAR_NAME read raw value from the environment variable
# file:/path read raw bytes from a file (PEM for RS*/ES*)
# <inline> anything else, used as-is (DEV / TEST ONLY)
SigningKey: env:KX_JWT_KEY
# Asymmetric algorithms only — separate verifier key for verifier-only deploys.
# SigningPublicKey: file:/etc/kx/kx-pub.pem
# --- Standard claims ---
Issuer: %APPNAME% # iss claim, verified at validation time
Audience: kx-app # aud claim, verified at validation time
# --- Lifetime / sliding ---
TokenLifetime: 3600 # exp - iat, in seconds (default 3600 = 1h)
SlidingThreshold: 600 # if (exp - now) < this, the auth gate re-issues
# the cookie with a fresh exp on the current request
ClockSkew: 60 # allowance for clock skew during exp/nbf/iat checks
# --- Cookie attributes ---
Cookie:
Name: kx_token # default kx_token — honoured everywhere, engine included
Path: # default = TKWebApplication.Current.Path (e.g. /myapp)
HttpOnly: True # default True — token is invisible to JavaScript
Secure: True # default True — only sent over HTTPS
SameSite: Lax # Strict | Lax | None | '' (omit) — default Lax
# --- Profile claims ---
Claims:
IncludeRoles: False
IncludeDB: True # 'db' claim from session.DatabaseName
IncludeDisplayName: True
IncludeLanguage: TrueHow it works at runtime
The orchestration lives in the base TKAuthenticator; the JOSE crypto lives in the engine (TKJWTEngine, in the opt-in Kitto.Auth.JWT). TKAuthenticator.IsJWTEnabled is simply "is there an Auth/JWT node?" — every step below is a no-op when it is False.
- Login (
POST /kx/login) — the authenticator performs its normal credential check. On successTKAuthenticator.AuthenticatecallsIssueToken, which delegates to the engine: it builds the claim set, signs it with the configured algorithm and key, and writes the compact form into thekx_tokencookie. - Subsequent requests —
TKWebEnginedecodes the JWT payload (base64url + JSON, without verifying the signature) to recover thesidclaim and bind the request to its server-sideTKWebSession. The decode is purely for routing; it grants no privilege, and the engine detects a JWT app by the presence of theAuth/JWTnode, not by a class check — so apps that don't use JWT pay for no JOSE dependency. - Auth gate — right after
ActivateInstance, the request filter calls the polymorphicTKAuthenticator.AuthorizeRequest. WhenIsJWTEnabled, the base delegates to the engine to validate the JWT signature plus theiss,aud,exp,nbfandiatclaims. If the token is invalid the cookie is cleared andIsAuthenticatedis set toFalse(the user is redirected to the login page on the next protected route). If it is valid, the session is hydrated from the verified claims (UserName, DisplayName, DatabaseName, Language). - Sliding expiration — when
(exp - now) < SlidingThresholdthe engine writes a freshSet-Cookieheader with an extended exp, keeping active users from being logged out mid-session. - Logout —
TKAuthenticator.Logoutasks the engine to write aSet-Cookie kx_token=; Max-Age=0header that immediately expires the cookie on the browser side, then clears the server-side auth data.
One switch, both worlds
Because the token layer is gated purely on the presence of the Auth/JWT node, the same authenticator class serves both a stateless-token deployment (block present) and a classic session-cookie deployment (block absent) with no code change. TKAuthenticator.IsJWTEnabled is the single predicate the framework tests.
Per-authenticator JWT state
The engine caches the parsed TKJWTConfig (and app name) on the authenticator itself, via the base-owned TKAuthenticator.JWTState: TObject slot — created lazily on first use, freed by the base in Destroy. The engine is a stateless singleton (IKXJWTEngine); anything it must remember per authenticator lives in that opaque slot, so multiple JWT-enabled apps in one process never share crypto state.
The per-request validated context (used by TKJWTAccessController to read kx_acl) is cached for the duration of the request in a unit-level TObjectDictionary<TThreadID, …> (in Source/Kitto.Auth.JWT.pas) protected by a TCriticalSection and owned by the unit, so finalization deterministically frees the holders. A threadvar of record was rejected because the Delphi runtime does not release managed members (strings, dynamic arrays) of records when a worker thread exits, and the Indy thread pool keeps ~20 workers alive for the server lifetime.
Reading authenticator keys from code
The authenticator's own keys live directly under Auth/ (only the token envelope keys are under Auth/JWT/), so application code can read them by absolute path or, more robustly, through EffectiveConfigNode, which returns the authenticator's own config node:
LNode := TKConfig.Instance.Authenticator.EffectiveConfigNode.FindNode('ValidatePassword');Production key sources
The recommended layout is: demo / dev registers a literal in UseKitto.pas initialization (acceptable because the binary is shipped with the demo data and the key is never reused for production); production registers a provider that loads from a real secret store. The same registry call covers both:
initialization
TKJWTSigningKeyRegistry.Instance.RegisterProvider('MyApp',
function: TKJWTSigningKey
begin
Result.Algorithm := TJOSEAlgorithmId.HS256;
Result.PrivateKey := MyVault.GetSecret('kx-prod-jwt'); // prod
// Result.PrivateKey := TEncoding.UTF8.GetBytes('demo-...'); // dev
end);Use an empty AppName to register a fallback provider used by any application in the process that does not have its own.
If you prefer to keep the key out of the binary entirely, omit the provider and let the YAML fallback handle it: SigningKey: env:KX_PROD_JWT_KEY reads the bytes from the named environment variable at the first JWT operation. file:/etc/kx/kx-prod.key reads them from a file on disk.
Security notes
- Cookie storage — the JWT lives in an HttpOnly cookie, not in
localStorage. JavaScript code on the page cannot read it, so an XSS bug cannot exfiltrate a session. - CSRF — the cookie is
SameSite=Lax(default), which means cross-origin POSTs do not carry the cookie. Top-level GET navigations from external sites do, but those cannot perform mutating actions because the session-changing endpoints are POST/PUT/DELETE only. - Key rotation — restart the server with a new
KX_JWT_KEY. All previously issued tokens become invalid (signature check fails), forcing every user to re-authenticate. - HS256 vs RS256 — HS256 is symmetric: the same secret signs and verifies. Adequate for monolithic deploys. RS256 is asymmetric and useful when verification happens in a separate service that should not be able to mint tokens.
- Algorithm
none— KittoX never accepts thenonealgorithm. The validator requires a signature.
Access control via kx_acl claim
When AccessControl: JWT is configured, the JWT engine queries KITTO_PERMISSIONS (plus all roles in KITTO_USER_ROLES) at login time, using the same SQL templates TKDBAccessController reads, and snapshots the resulting grant rows into a kx_acl claim of the JWT. A TKJWTAccessController (registered as 'JWT') reads that claim on every IsAccessGranted call without round-tripping to the database — same matching semantics (wildcards, regex, mode CSV, FALSE-priority for standard modes), zero DB hit.
The kx_acl claim is auto-derived from the access-controller choice: there is no IncludeACL flag to set. Choose AccessControl: JWT and the framework wires everything up.
Auth: DB
ReadUserCommandText: ...
JWT:
SigningAlgorithm: HS256
...
AccessControl: JWT
# ReadPermissionsCommandText: ... (optional — overrides TKDBAccessController defaults)
# ReadRolesCommandText: ... (optional)Add Kitto.AccessControl.JWT to your UseKitto.pas (alongside Kitto.Auth.JWT) so the 'JWT' access-controller class id is registered at startup. Kitto.AccessControl.DB stays useful only because its SQL templates / class id are still consumed at login time to build the claim:
uses
...
, Kitto.Auth.JWT
, Kitto.AccessControl.JWT // registers 'JWT' AccessController
, Kitto.AccessControl.DB; // SQL templates reused at login to build kx_aclBehavior
AccessControl: JWT is closed-world: the claim is the sole source of truth.
| Situation | Result |
|---|---|
kx_acl covers (resource, mode) with TRUE | granted |
kx_acl covers it with FALSE (deny) | denied — FALSE wins for standard modes even if other rows say TRUE |
kx_acl does not cover (resource, mode) | denied (no DB fallback) |
kx_acl claim absent (e.g. token issued by a different controller) | denied |
If you need DB-driven evaluation per request, configure AccessControl: DB instead. The JWT envelope remains valid for authentication; the two settings are independent.
Trade-off
The kx_acl claim is snapshotted at login. A change applied to KITTO_PERMISSIONS or KITTO_USER_ROLES mid-session will not take effect until the user logs in again. Pick TokenLifetime accordingly or expose an admin action that forces re-login (deletion of the user's session from TKWebSessions plus a redirect from the next request).
When grants change frequently, switch to AccessControl: DB (every check hits the DB / cache) and keep the JWT envelope only for the UserName-from-token plumbing.
Token size
A typical KittoX install has tens of permission rows. Each is encoded as a 3-element JSON array [pattern, modes, grant], which adds about 30-100 bytes per row to the cookie. 50 rows ≈ 3-5 KB token, comfortably within the 4 KB-per-cookie spec limit.
Cookies emitted with a JWT envelope
| Cookie | Set by | Purpose |
|---|---|---|
kx_token | server (login + sliding refresh) | The JWT itself: identity + claims, HttpOnly, Secure, SameSite=Lax, path-scoped on AppPath |
<AppName> | server (every response) | The session-id correlator, HttpOnly, SameSite=Lax, Path=/ |
kx_sw | client JavaScript (Home/Templates/_Page.html) | Screen size for responsive HomeView selection (no relation to auth) |
The session-id cookie named after AppName (e.g. MyAppX) is emitted also with a JWT envelope active. The sid claim carries the same information, but the cookie is still needed for two kinds of request that would otherwise have nothing to identify a session by: those that arrive before a token exists — the login page and every file it loads — and those that fall outside the token cookie's path, which is scoped to AppPath while static resources live under /res. A request presenting no identifier gets a new session, so without the cookie a single login page would produce one anonymous session per file it loads.
This is not a weakening of session handling: a request is still matched to a session only by an identifier it presents, never by its client address. Secure is deliberately not set on this cookie, because applications are routinely served over plain HTTP on an intranet, where a Secure cookie would never come back.
The multi-database kx_db cookie is still not emitted with a JWT envelope: the db claim carries it inside the signed token. After upgrading from a non-JWT build, a stale kx_db present in the browser is cleared on the next session-end / login POST.
Under non-JWT auth, kx_db was a 30-day cookie that pre-selected the last picked database environment on the login form. With a JWT envelope the db claim lives inside kx_token (typically 1-hour lifetime, slid every active request), so when the token finally expires the next login form pre-selects DefaultDatabaseName instead of the last picked one. During an active session there is no observable difference: the JWT slides on every request and the chosen database stays.
Limitations
- The server session is still required for non-serializable state (open controllers, in-memory master-detail stores, gnugettext instance). The JWT replaces only the credential / session-id correlator. The session itself stays.
- Permission changes mid-session are not reflected until the next login: the
rolesandkx_aclclaims are snapshotted at login. Force a re-login to apply new grants. - Only the claims are rehydrated on each request: user name, language, database, display name. Anything else the login had read into the auth data — a profile id, an application-specific flag, a custom column returned by
ReadUserCommandText— lives in the server-side session.ReadUserCommandTextruns at login only, by design: the token carries asidclaim and correlates to a server-side session, which stays the authority on the user's data. - Revoking access has a window. Because the user row is not re-read, disabling or deleting an account does not take effect until the token expires. Size that window with
TokenLifetime, and force a logout when you withdraw someone's access. - The signing key is loaded once per application lifetime (cached after first use). Updating the key requires a restart.
Extension points for OIDC / SAML
Phase C will add TKOIDCAuthenticator and TKSAMLAuthenticator as redirect-based authenticators. The architecture is ready:
- An OIDC authenticator will set up an OAuth2 Authorization Code + PKCE flow, exchange the IdP id_token for a KittoX JWT, and let the rest of the framework keep running with the same JWT envelope — the authenticator carries its own
JWT:block just like a classic one. TKJWTEngine.BuildContextis where identity claims are assembled — Phase C authenticators will supply IdP-mapped claims (preferred_username,tid,oid,roles) into the internal token.
See KITTOX.md for the latest status of the JWT phases.
See also
- Auth configuration — the umbrella reference for
Auth:inConfig.yaml. - Multiple Databases —
DatabaseChoicesbecomes thedbclaim with a JWT envelope. - Login controller — the login form template, environment combo, branding hooks.
