Skip to content

Security and access

Authentication middleware, credential types, session model, and access control overview.

Intended audience: Stakeholders, Business analysts, Solution architects, Developers, Testers

Learning outcomes by role

Stakeholders

  • Relate credential types (JWT, API key, OAuth) to business risk and revocation expectations at a high level.

Business analysts

  • Specify expected HTTP outcomes (401 vs 403) and user-visible auth flows for requirements and defects.

Solution architects

  • Explain middleware versus route-level enforcement and where Redis and Postgres participate in the trust model.

Developers

  • Trace AuthenticationMiddleware and TenantContextMiddleware behavior and route dependencies (authenticated, roles_allowed, org_context).

Testers

  • Design negative tests for public paths, invalid JWT, missing Redis session, API key hash miss, and org scope errors.

Every protected request in Cadence has to answer three questions before reaching a route handler: Who are you? (authentication), Which organization are you acting for? (tenant scope), and Are you allowed to do this? (authorization). The middleware handles the first two; route-level dependencies handle the third.

When a session ends — by logout, by an admin action, or by Redis being cleared — the jti row disappears from Redis and every subsequent request using that JWT returns a 401, even though the JWT’s cryptographic signature is still valid. This is the key design property that enables instant revocation.

  • JWT validity ≠ active session — Cadence always checks Redis for interactive sessions; a signed JWT alone is not enough.
  • API key revocation is immediate — Deleting or disabling the key row in the database terminates access on the next request.
  • OAuth and social login — Browser-based flows generate the same JWT + Redis session; the difference is only in how the token was obtained.

A failed request coming back as 401 is a different problem from a 403. A 401 means the server does not recognize the caller at all — the credential is missing, invalid, or the session was revoked. A 403 means the server knows who the caller is but they do not have the right permission or membership for that operation. Keeping defects aligned to this distinction matters when writing tickets and acceptance criteria.

  • Observable contracts — Same endpoint, two different failures: 401 (unknown caller or dead session) versus 403 (known caller, wrong org or permission).
  • OAuth and profiles — Browser-led flows use OAuth endpoints listed in the API reference below; profile edits go through /api/me patterns.

The diagram below traces the two credential types from the incoming request through middleware to the route handler.

flowchart TD
    R[Incoming request] --> P{Public path?}
    P -->|Yes| H[Route handler]
    P -->|No| A{Credential present?}
    A -->|Authorization: Bearer JWT| V[JWTAuth.validate — HS256 then optional RS256]
    A -->|X-API-KEY| K[api_key_repository.get_by_hash]
    A -->|None| E1[401 AuthenticationError]
    V -->|valid signature| N[TenantContextMiddleware]
    V -->|invalid| E2[401 InvalidTokenError]
    K -->|row found| N
    K -->|not found| E3[401 Invalid API key]
    N -->|Bearer: decode jti → Redis GET session:jti| J{Session in Redis?}
    N -->|X-API-KEY: build ApiKeyTokenSession| S2[Synthetic session from DB row]
    J -->|yes| H
    J -->|no| E4[401 — JWT valid but session gone]
    S2 --> H

JWTAuth.validate only checks the cryptographic validity of the JWT. The live session data — permissions, org memberships — is loaded in TenantContextMiddleware from Redis. If there is no session:{jti} key in Redis, request.state.session remains None and any route calling require_session returns 401.

AuthenticationMiddleware runs on every non-public path. It accepts exactly one of two credentials:

Bearer JWT — The token is extracted from the Authorization: Bearer <token> header. JWTAuth.validate first attempts decode with the internal HMAC key (CADENCE_SECRET_KEY, default algorithm HS256). If that fails and CADENCE_THIRD_PARTY_JWT_SECRET_KEY is configured, a second decode is attempted using the third-party algorithm (default RS256). If both fail, the middleware raises InvalidTokenError and the request returns 401.

API key — The raw key is taken from the X-API-KEY header. The middleware hashes it and calls api_key_repository.get_by_hash. If the row is not found, the request returns 401 Invalid API key. If found, the row is placed on request.state.api_key_row for downstream use.

There is no fallback between the two. Authorization: Bearer is for JWTs only; X-API-KEY is for API keys only.

TenantContextMiddleware runs immediately after authentication. It resolves which organization the caller is acting for and builds the session object that the rest of the request reads.

For Bearer JWTs: the middleware decodes the jti claim from the token (using the same HMAC key), then calls session_store.get_session(jti) — a Redis GET session:{jti}. The result is a BearerTokenSession containing the caller’s permissions, org memberships, and metadata as they were at login time. If Redis returns nothing, request.state.session stays None and any route requiring authentication will return 401.

For API keys: the middleware reads request.state.api_key_row, fetches the key owner’s org memberships from user_org_membership_repository, calls touch_last_used best-effort, and builds an ApiKeyTokenSession in memory. No Redis key is created or read.

BearerTokenSession fields (stored in Redis, loaded per request):

FieldTypePurpose
jtistrUnique session ID; also the Redis key suffix (session:{jti})
user_idstrWho the session belongs to
global_permissionsList[str]Platform-wide permissions (sys_admin, system-level ops)
org_permissionsDict[str, List[str]]Per-org permission lists, keyed by org ID
membership_org_idsList[str]Orgs the user belongs to
org_admin_idsList[str]Orgs where the user has admin rights
client_idstr | NoneOAuth2 client that issued the session
auth_methodstrAlways "oauth2" for interactive JWT sessions
created_at / expires_atstrISO timestamps for the session lifetime
granted_profile_claimsList[str] | NoneOIDC claims consented to during OAuth flow

ApiKeyTokenSession fields (built in memory, never stored in Redis):

FieldTypePurpose
jtistrFormat: apikey:{row_id} — not a Redis key
user_idstrKey owner
flat_scopesList[str]Permissions after dangerous scopes are stripped
membership_org_idsList[str]Orgs the key owner belongs to (loaded from DB)
org_admin_idsList[str]Orgs where the key owner is admin
auth_methodstrAlways "api_key"

Route handlers access the resolved context via the SecurityContext and TenantContext dataclasses. SecurityContext is org-agnostic (just user_id and session). TenantContext is org-scoped:

FieldSource
user_idsession.user_id
org_idX-ORG-ID header or path parameter
is_sys_adminDerived from session permissions
is_org_adminsession.is_admin_of(org_id)
sessionBearerTokenSession or ApiKeyTokenSession

Permission checks are not in the middleware. They run in route dependencies. require_session(request) raises AuthenticationError if request.state.session is None. Route dependencies like authenticated and roles_allowed(permission) use the session’s has_permission method to enforce specific permissions. Org membership is enforced by org_context — see Multi-tenancy and Role-based access control.

SessionStoreRepository manages the following Redis keys for interactive JWT sessions:

Key patternValueTTL
session:{jti}JSON BearerTokenSession payloadAccess token TTL (defaults from global_settings, bootstrapped from CADENCE_ACCESS_TOKEN_TTL_SECONDS, default 10800 s)
user_sessions:{user_id}Set of active jti valuesEvicted per-entry on logout
refresh:{refresh_jti}Refresh token rowRefresh token TTL (default 604800 s)
user_refresh_sessions:{user_id}Set of active refresh jti valuesEvicted per-entry on revoke
oauth_state:{state}OAuth2 state artifactShort-lived handoff TTL
oauth2_code:{code}One-time authorization codeShort-lived, consumed on exchange

API key sessions use none of these keys.

Token TTLs are runtime settings in the global_settings table — not direct per-request env vars. On first bootstrap they are seeded from AppSettings (CADENCE_ACCESS_TOKEN_TTL_SECONDS / CADENCE_REFRESH_TOKEN_TTL_SECONDS, defaults 10800 s and 604800 s).

MethodPathAuthDescription
POST/oauth2/tokenPer grantIssue access + refresh tokens (password, authorization_code, refresh_token grants)
GET/.well-known/openid-configurationPublicOIDC discovery document
GET/oauth2/authorizePublicStart authorization code flow — redirects to consent UI
GET/oauth2/consent/contextPublicDecode consent handoff JWT for the consent UI
POST/oauth2/consent/decisionBearer JWTSubmit user approval or denial; issues a one-time authorization code
GET/oauth2/userinfoBearer JWTOIDC profile claims for consented scopes
POST/oauth2/revokePublic (form)Revoke access or refresh token by value
POST/oauth2/introspectPublic (form)Returns active, sub, client_id for a token
DELETE/api/auth/logoutBearer JWTRevoke current session; include X-Refresh-Token to also revoke the refresh token
GET/api/meBearer JWTCurrent user profile
PATCH/api/me/profileBearer JWTUpdate profile fields or password
GET/api/me/orgsBearer JWTList caller’s org memberships with role

PATCH /api/me/profile accepts: display_name, email, avatar_url, locale, timezone, bio, current_password (required when changing password), new_password (min 8 chars).

VariableDefaultPurpose
CADENCE_SECRET_KEY(placeholder)HMAC key for JWT signing — must be a strong random value in production
CADENCE_JWT_ALGORITHMHS256JWT signing algorithm
CADENCE_THIRD_PARTY_JWT_SECRET_KEYOptional public key for third-party JWT acceptance
CADENCE_THIRD_PARTY_JWT_ALGORITHMRS256Algorithm for third-party JWTs
CADENCE_ENCRYPTION_KEY(placeholder)64-hex-char AES-256 key for API key storage — must be set in production
CADENCE_REDIS_URLredis://localhost:6379Redis connection for the session store
CADENCE_REDIS_DEFAULT_DB0Redis DB index for sessions and OAuth artifacts
SymptomCauseFix
401 Authentication requiredNo Authorization or X-API-KEY header on a protected pathAdd Authorization: Bearer <token> or X-API-KEY: <key>
401 Invalid authentication tokenJWT signature invalid, wrong algorithm, or signed with wrong keyVerify CADENCE_SECRET_KEY; reissue a token
401 Invalid API keyKey hash not found in database (deleted or wrong value)Verify key; revoke and re-create if lost
401 despite a valid JWT stringRedis session was deleted (logout, revoke, or admin action)Re-authenticate to create a new session
403 on org-scoped routeCaller not a member of the org in X-ORG-ID, or missing permissionVerify membership and role; pass the correct X-ORG-ID
OAuth redirect loopConsent URL or redirect_uri misconfiguredSee OAuth2 and BFF
422 on profile updatenew_password set without current_passwordInclude current_password whenever changing password