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.

Cadence separates identity (who you are), authorization (what you may do), and tenant scope (which organization’s data you touch). Every protected API call must prove who is calling (login or API key); the system then loads permissions and org memberships. Expect 401 when credentials are missing or invalid, 403 when the user is known but not allowed for that org or action. On non-public paths, AuthenticationMiddleware runs first (see How the platform works), then TenantContextMiddleware fills request.state.session from Redis (interactive JWT) or builds a session from the API key row. Route dependencies such as authenticated and roles_allowed(...) gate access; org membership is often enforced with org_context (see Multi-tenancy and RBAC).

  • Trust boundaries — Cryptographic JWT validity does not imply an active session; Redis holds interactive sessions by jti; API keys resolve from PostgreSQL without Redis session rows.
  • Revocation story — Interactive sessions end with logout and Redis deletion; API keys are revoked by changing or deleting the key row.
  • Observable contracts — Same endpoint may return 401 (unknown caller or dead session) versus 403 (known caller, wrong org or permission)—keep defect taxonomy aligned.
  • OAuth and profiles — Browser-led flows use OAuth endpoints in the API reference table; profile edits go through /api/me patterns documented below.
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

Important: JWTAuth.validate only checks cryptographic validity of the JWT. The full BearerTokenSession is loaded in TenantContextMiddleware via session_store.get_session(jti). If Redis has no row for that jti, request.state.session stays null and route-level require_session returns 401.

JWTAuth first attempts HMAC (HS256) using CADENCE_SECRET_KEY. If CADENCE_THIRD_PARTY_JWT_SECRET_KEY is set, a second decode with the third-party algorithm (default RS256) is attempted before InvalidTokenError.

API keys: send the secret in the X-API-KEY header. The Authorization: Bearer … path is for JWTs only (enforced in authentication middleware).

CredentialTransportSession backingRevocation
Bearer JWT (interactive)Authorization: Bearer <jwt>Redis session:{jti} — instant on deleteDELETE /api/auth/logout, POST /oauth2/revoke
API key (cdk_…)X-API-KEY header (lookup by hash)Not stored in Redis — ApiKeyTokenSession built per request from DB row + membershipsDelete or disable the key row
OAuth2 authorization codeBrowser → consent → code → token exchangeSame JWT + Redis model as interactiveSame as Bearer JWT
Authentication middleware dispatch
async def dispatch(self, request: Request, call_next):
if self._is_public_path(request.url.path):
return await call_next(request)
auth_header = request.headers.get("Authorization")
api_key_header = request.headers.get("X-API-KEY")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
self.jwt_auth.validate(token) # raises InvalidTokenError if invalid
elif api_key_header:
row = await repo.get_by_hash(api_key_header)
if not row:
raise AuthenticationError("Invalid API key")
request.state.api_key_row = row # resolved to session downstream
else:
raise AuthenticationError(
"Authentication required (Bearer token or X-API-KEY)"
)

After AuthenticationMiddleware, TenantContextMiddleware either:

  • Bearer JWT: decodes jti with the same HMAC secret (_decode_jwt_jti in tenant context middleware) and loads BearerTokenSession from Redis; sets request.state.token_jti.
  • API key: reads request.state.api_key_row, loads org memberships from user_org_membership_repository, optionally touch_last_used on the key, builds ApiKeyTokenSession with jti="apikey:{id}" (not stored in Redis).

Permission checks are not in the middleware. They run in route dependencies (for example authenticated, roles_allowed), in org_context, or inside handlers when a rule is specific to that endpoint.

Handlers use require_session(request) from tenant context middleware (via SecurityContext dependencies in authorization middleware) to obtain a TokenSessionProtocol. If request.state.session is missing (e.g. expired Redis session), this raises AuthenticationError.

TenantContext (user + org + session) is built by org-scoped dependencies that combine session with X-ORG-ID or path org_id — see Multi-tenancy.

FieldSourceUsage
user_idsession.user_idWho is acting (used in services and audits)
is_sys_adminSession / permissionsPlatform-wide administrator
sessionBearerTokenSession or ApiKeyTokenSessionPermission checks and org lists
org_idHeader or path parameterWhich organization’s data applies

SessionStoreRepository documents keys (prefixes from security constants):

  • session:{jti} — JSON session payload, TTL aligned with access token lifetime
  • user_sessions:{user_id} — Set of active jti values for logout-all semantics
  • refresh:{refresh_jti} — refresh token row; user_refresh_sessions:{user_id} tracks refresh jtis
  • oauth_state:{state}, oauth2_code:{code} — OAuth handoff and OAuth2 code artifacts

API-key sessions do not use these keys.

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 (email, name, picture) 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; pass X-Refresh-Token header to also revoke refresh
GET/api/meBearer JWTCurrent user profile (user_id, is_sys_admin, username, email, display_name…)
PATCH/api/me/profileBearer JWTUpdate display_name, email, avatar_url, locale, timezone, bio, or password
GET/api/me/orgsBearer JWTList caller’s org memberships with role

PATCH /api/me/profile accepts:

FieldTypeNotes
display_namestring | nullVisible name in the UI
emailstring | nullMust be unique across all users
avatar_urlstring | nullURL to profile image
localestring | nullBCP-47 locale tag
timezonestring | nullIANA timezone name
biostring | nullFree-form biographical text
current_passwordstring | nullRequired when new_password is set
new_passwordstring | nullMinimum 8 characters
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 RSA 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 database index for sessions and OAuth2 artifacts

Token TTLs (access_token_ttl_seconds, refresh_token_ttl_seconds) are runtime settings in the global_settings table, not env vars. Defaults: 1800 s (30 min) and 604800 s (7 days).

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, expired, or wrong value)Verify key; revoke and re-create if lost
401 despite 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 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