Skip to content

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:

pascal
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

yaml
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: 3600

If 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

yaml
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: True

How it works at runtime

  1. Login (POST /kx/login)TKJWTAuthenticator.InternalAuthenticate delegates 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 the kx_token cookie.
  2. Subsequent requestsTKWebEngine.GetSessionIdFromRequest decodes the JWT payload (base64url + JSON, without verifying the signature) to recover the sid claim and bind the request to its server-side TKWebSession. 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.
  3. Auth gate — Right after ActivateInstance, TKWebApplication.DoHandleRequest calls the polymorphic TKAuthenticator.AuthorizeRequest virtual method. The base class default is a no-op; TKJWTAuthenticator overrides it to validate the JWT signature plus the iss, aud, exp, nbf and iat claims. If the token is invalid the cookie is cleared and IsAuthenticated is set to False (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).
  4. Sliding expiration — when (exp - now) < SlidingThreshold the auth gate calls TKJWTAuthenticator.SlideToken to write a fresh Set-Cookie header with an extended exp, keeping active users from being logged out mid-session.
  5. LogoutTKJWTAuthenticator.Logout writes a Set-Cookie kx_token=; Max-Age=0 header 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:

pascal
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 the none algorithm. 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.

yaml
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:

pascal
uses
  ...
  , Kitto.Auth.JWT
  , Kitto.AccessControl.JWT      // registers 'JWT' AccessController
  , Kitto.AccessControl.DB;      // SQL templates reused at login to build kx_acl

Behavior

AccessControl: JWT is closed-world: the claim is the sole source of truth.

SituationResult
kx_acl covers (resource, mode) with TRUEgranted
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 roles and (future) kx_acl claims 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: OIDC 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 its existing JWT envelope.
  • TKJWTAuthenticator.BuildContext is virtual — Phase C inner authenticators will override it to map IdP-supplied claims (preferred_username, tid, oid, roles) into the internal token.
  • TKJWTAuthenticator.GetLoginUIMode returns lumRedirect for 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

Released under Apache License, Version 2.0.