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).
Summary for stakeholders
Section titled “Summary for stakeholders”- 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.
Business analysis
Section titled “Business analysis”- 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/mepatterns documented below.
Architecture overview
Section titled “Architecture overview”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).
Credential types
Section titled “Credential types”| Credential | Transport | Session backing | Revocation |
|---|---|---|---|
| Bearer JWT (interactive) | Authorization: Bearer <jwt> | Redis session:{jti} — instant on delete | DELETE /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 + memberships | Delete or disable the key row |
| OAuth2 authorization code | Browser → consent → code → token exchange | Same JWT + Redis model as interactive | Same as Bearer JWT |
Authentication middleware
Section titled “Authentication middleware”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
jtiwith the same HMAC secret (_decode_jwt_jtiin tenant context middleware) and loadsBearerTokenSessionfrom Redis; setsrequest.state.token_jti. - API key: reads
request.state.api_key_row, loads org memberships fromuser_org_membership_repository, optionallytouch_last_usedon the key, buildsApiKeyTokenSessionwithjti="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.
Route dependencies: session resolution
Section titled “Route dependencies: session resolution”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.
| Field | Source | Usage |
|---|---|---|
user_id | session.user_id | Who is acting (used in services and audits) |
is_sys_admin | Session / permissions | Platform-wide administrator |
session | BearerTokenSession or ApiKeyTokenSession | Permission checks and org lists |
org_id | Header or path parameter | Which organization’s data applies |
Redis session layout (interactive JWT)
Section titled “Redis session layout (interactive JWT)”SessionStoreRepository documents keys (prefixes from security constants):
session:{jti}— JSON session payload, TTL aligned with access token lifetimeuser_sessions:{user_id}— Set of activejtivalues for logout-all semanticsrefresh:{refresh_jti}— refresh token row;user_refresh_sessions:{user_id}tracks refresh jtisoauth_state:{state},oauth2_code:{code}— OAuth handoff and OAuth2 code artifacts
API-key sessions do not use these keys.
API reference
Section titled “API reference”| Method | Path | Auth | Description |
|---|---|---|---|
POST | /oauth2/token | Per grant | Issue access + refresh tokens (password, authorization_code, refresh_token grants) |
GET | /.well-known/openid-configuration | Public | OIDC discovery document |
GET | /oauth2/authorize | Public | Start authorization code flow — redirects to consent UI |
GET | /oauth2/consent/context | Public | Decode consent handoff JWT for the consent UI |
POST | /oauth2/consent/decision | Bearer JWT | Submit user approval or denial; issues a one-time authorization code |
GET | /oauth2/userinfo | Bearer JWT | OIDC profile claims (email, name, picture) for consented scopes |
POST | /oauth2/revoke | Public (form) | Revoke access or refresh token by value |
POST | /oauth2/introspect | Public (form) | Returns active, sub, client_id for a token |
DELETE | /api/auth/logout | Bearer JWT | Revoke current session; pass X-Refresh-Token header to also revoke refresh |
GET | /api/me | Bearer JWT | Current user profile (user_id, is_sys_admin, username, email, display_name…) |
PATCH | /api/me/profile | Bearer JWT | Update display_name, email, avatar_url, locale, timezone, bio, or password |
GET | /api/me/orgs | Bearer JWT | List caller’s org memberships with role |
Profile fields
Section titled “Profile fields”PATCH /api/me/profile accepts:
| Field | Type | Notes |
|---|---|---|
display_name | string | null | Visible name in the UI |
email | string | null | Must be unique across all users |
avatar_url | string | null | URL to profile image |
locale | string | null | BCP-47 locale tag |
timezone | string | null | IANA timezone name |
bio | string | null | Free-form biographical text |
current_password | string | null | Required when new_password is set |
new_password | string | null | Minimum 8 characters |
Configuration
Section titled “Configuration”| Variable | Default | Purpose |
|---|---|---|
CADENCE_SECRET_KEY | (placeholder) | HMAC key for JWT signing — must be a strong random value in production |
CADENCE_JWT_ALGORITHM | HS256 | JWT signing algorithm |
CADENCE_THIRD_PARTY_JWT_SECRET_KEY | — | Optional RSA public key for third-party JWT acceptance |
CADENCE_THIRD_PARTY_JWT_ALGORITHM | RS256 | Algorithm 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_URL | redis://localhost:6379 | Redis connection for the session store |
CADENCE_REDIS_DEFAULT_DB | 0 | Redis 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).
Verification and quality
Section titled “Verification and quality”Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Fix |
|---|---|---|
401 Authentication required | No Authorization or X-API-KEY header on a protected path | Add Authorization: Bearer <token> or X-API-KEY: <key> |
401 Invalid authentication token | JWT signature invalid, wrong algorithm, or signed with wrong key | Verify CADENCE_SECRET_KEY; reissue a token |
401 Invalid API key | Key hash not found in database (deleted, expired, or wrong value) | Verify key; revoke and re-create if lost |
401 despite valid JWT string | Redis session was deleted (logout, revoke, or admin action) | Re-authenticate to create a new session |
403 on org-scoped route | Caller not a member of the org in X-ORG-ID, or missing permission | Verify membership and role; pass correct X-ORG-ID |
| OAuth redirect loop | Consent URL or redirect_uri misconfigured | See OAuth2 and BFF |
422 on profile update | new_password set without current_password | Include current_password whenever changing password |