JWT Authenticator
Auth: JWT is a wrapper authenticator that delegates the actual credential check to an inner authenticator (DB, TextFile, custom...) and, on success, issues a self-contained signed JWT in an HttpOnly cookie. The token replaces the legacy opaque session id cookie and carries selected user-profile claims (sub, name, db, lang, roles).
It is opt-in and additive: existing applications running on Auth: DB, Auth: TextFile or a custom authenticator are untouched and do not pull in the underlying delphi-jose-jwt library. To use JWT in your app, add Kitto.Auth.JWT to the uses clause of UseKitto.pas (see Opt-in below) and add the JOSE source folder to the .dproj unit search path.
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 Phase C extension).
Opt-in from UseKitto.pas
Add Kitto.Auth.JWT to your application's UseKitto.pas so the 'JWT' class id is registered with TKAuthenticatorRegistry at startup, and register a key provider in the unit's 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 inner authenticator
, Kitto.Auth.JWT; // JWT envelope
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/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)Minimal configuration
Auth: JWT
Inner: DB
ReadUserCommandText: |
select USER_NAME, PASSWORD_HASH from KITTO_USERS
where UPPER(USER_NAME) = UPPER(:USER_NAME)
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/SigningKey is read from the YAML — set the KX_JWT_KEY environment variable (or use file:/path or inline literal) before starting the server.
Full configuration reference
Auth: JWT
# === Inner authenticator ===
# Any class id registered in TKAuthenticatorRegistry can be used:
# DB / DBCrypt / TextFile / OSDB / DBServer / app-specific custom.
# All YAML keys the inner authenticator normally reads at the Auth/ level
# go under Auth/Inner/ here. KittoX reads them transparently via
# TKAuthenticator.EffectiveConfigNode.
Inner: DB
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}$
# === 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
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: True
IncludeDB: True # 'db' claim from session.DatabaseName
IncludeDisplayName: True
IncludeLanguage: TrueHow it works at runtime
- Login (
POST /kx/login) —TKJWTAuthenticator.InternalAuthenticatedelegates to the inner authenticator. On success the wrapper builds a JWT claim set, signs it with the configured algorithm and key, and writes the compact form into thekx_tokencookie. - Subsequent requests —
TKWebEngine.GetSessionIdFromRequestdecodes 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. The engine intentionally has no dependency on the JOSE library — apps that don't use JWT do not pay for that dependency. - Auth gate — Right after
ActivateInstance,TKWebApplication.DoHandleRequestcalls the polymorphicTKAuthenticator.AuthorizeRequestvirtual method. The base class default is a no-op;TKJWTAuthenticatoroverrides it 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 the token is valid, the session is hydrated from the verified claims (UserName, DisplayName, DatabaseName, Language). - Sliding expiration — when
(exp - now) < SlidingThresholdthe auth gate callsTKJWTAuthenticator.SlideTokento write a freshSet-Cookieheader with an extended exp, keeping active users from being logged out mid-session. - Logout —
TKJWTAuthenticator.Logoutwrites aSet-Cookie kx_token=; Max-Age=0header that immediately expires the cookie on the browser side, then delegates to the inner authenticator.
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 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, TKJWTAuthenticator 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 new 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: JWT
Inner: DB
...
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. Auth: JWT 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 Auth: JWT 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.
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
rolesand (future)kx_aclclaims are snapshotted at login. Force a re-login to apply new grants. - 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 inner authenticators. The architecture is ready:
Auth: JWT / Inner: OIDCwill 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 its existing JWT envelope.TKJWTAuthenticator.BuildContextis virtual — Phase C inner authenticators will override it to map IdP-supplied claims (preferred_username,tid,oid,roles) into the internal token.TKJWTAuthenticator.GetLoginUIModereturnslumRedirectfor external auth, telling the login template to render a "Login with X" button instead of a UserName/Password form.
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 under JWT. - Login controller — the login form template, environment combo, branding hooks.
