Authentication, MFA & Token Internals
Version 1 · Deep dive on auth flows, MFA/TOTP mechanics, session vs token model, and how tokens are generated/validated — requested by ClickUp task 86d40a7qe (official developer/integration guide)
Historical versionAuthentication, MFA & Token Internals
Written for the "official developer and integration guide" requested in ClickUp task 86d40a7qe. Complements API Reference (endpoint shapes) and Administrator User Guide (day-to-day operation) with the underlying mechanics: how a login actually completes, how MFA works, and how tokens are generated and validated.
The two authentication flows
QDBAuth supports two integration styles, both enforcing the same password + MFA check underneath:
1. Browser SSO redirect (used by QDBAdmin)
A consuming app redirects the user's browser to
/login/?return_url=<consuming-app-url>. QDBAuth owns the entire login UI
(its own HTML pages). On success, it redirects the browser back to
return_url with a signed token query parameter (tokens.make_token) the
consuming app verifies itself. No API calls, no client secret — trust comes
from the browser redirect plus the signed token.
2. JSON API, two-step (used by the CMS, and TD's in-progress migration)
The consuming app builds its own login UI and calls QDBAuth's API directly:
POST /api/login/ (password) → POST /api/verify-2fa/ (MFA code) → tokens.
Requires the X-QDB-Client-Secret header on every call. See
API Reference for exact request/response shapes.
Both flows enforce the same rule: a real account can never complete login without its MFA code being verified. There is no password-only login path for a real user in either flow.
MFA (TOTP only)
QDBAuth supports exactly one MFA method: TOTP (Time-based One-Time Password, e.g. Google Authenticator, Microsoft Authenticator, Authy) — not SMS, not push notifications, not WebAuthn/passkeys.
- Enrollment: happens once, via the emailed
/verify-signup/link sent when an admin creates the account. The user scans a QR code (pyotp.TOTP(secret).provisioning_uri(...)) with their authenticator app, enters the resulting 6-digit code to confirm, then sets their real password. Until this is done,totp_confirmedis false and login is blocked with "Please verify your account first." - Verification code window:
pyotp.TOTP(secret).verify(code, valid_window=1)— accepts the current 30-second code plus one step of clock drift either side (±30s), not a fixed single instant. - Re-enrollment: an admin can force a user to re-enroll (
force_reauth/site_credentials.force_reauth) — generates a fresh secret and clearstotp_confirmed, so the old QR code stops working and the next login shows a new one. No self-service "lost my phone" recovery flow exists — this currently requires admin action. - Per-site MFA secrets: an account with per-site credentials (see Overview) has a separate TOTP secret per site, not one secret shared across every site it can access.
- Dev-only bypass:
QDB_TOTP_TEST_CODEenv var lets a fixed code satisfy any account with nototp_secret— this only ever applies to the synthetic test-admin account (QDB_TEST_ADMIN_EMAIL/QDB_TEST_ADMIN_PASSWORD), never to a real user row, so it can't be used to bypass a real person's MFA.
Sessions vs. tokens — two different mechanisms
These are easy to conflate; they serve different flows and don't overlap:
| Browser SSO flow | JSON API flow | |
|---|---|---|
| State during login | Django session, in a signed cookie (SESSION_ENGINE = signed_cookies) — not stored server-side |
Signed pending_token string, held by the caller between /api/login/ and /api/verify-2fa/ |
| State after login | Nothing on QDBAuth's side — a one-shot signed token handed to the consuming app in the redirect |
access_token + refresh_token, held by the consuming app |
| Server-side storage | None (signed cookie) | Only the refresh token, and only as a SHA-256 hash (auth_refresh_tokens) |
Because sessions are signed cookies rather than server-side session rows, QDBAuth's app servers are stateless — any instance can handle any request, which matters for the horizontal-scaling discussion in Overview's Reliability section.
How tokens are generated and validated
Not JWTs. Every token (access_token, pending_token, the SSO redirect
token, the signup-verification token) is produced by Django's
django.core.signing module (apps/auth/tokens.py), not a JWT library.
Format: base64(payload):base64(timestamp):base64(HMAC-SHA256 signature) —
colon-separated, no algorithm header, not decodable by a generic JWT library.
Signing keys: SECRET_KEY (Django's own, used for the signup-verification
token only) or QDB_SSO_SHARED_SECRET (used for every token a consuming app
ever sees or verifies — the SSO redirect token and the access token).
Consuming apps must be configured with the exact same QDB_SSO_SHARED_SECRET
value to verify tokens themselves, or call /api/whoami/ and let QDBAuth do
the verification for them.
Salts — every token type uses a distinct salt, so a token issued for one purpose can never be replayed as another type even though they may share a signing key:
| Token | Salt | Max age |
|---|---|---|
| SSO redirect token | qdbauth.sso-token |
QDB_SSO_TOKEN_MAX_AGE (60s default) |
| Signup verification token | qdbauth.signup-verify |
QDB_SIGNUP_VERIFY_MAX_AGE (24h default) |
| Pending-login token (API flow) | qdbauth.pending-login |
fixed 5 minutes |
| Access token | qdbauth.access-token |
QDB_ACCESS_TOKEN_MAX_AGE (15 min default) |
Validation is pure signature + timestamp checking — no database lookup,
no network call required (tokens.read_access_token,
tokens.read_token, etc.). A tampered or expired token fails to loads()
and is treated as invalid. Only the refresh token requires a database check
(it's opaque, hashed, and revocable — the one credential type that must be
checkable/killable server-side).
Security requirements for a consuming app
- Never expose
QDB_SSO_SHARED_SECRETto a browser/client — it must only be sent from a trusted backend. If your integration platform executes API calls client-side rather than server-side, using this shared secret in a header is not safe as-is. - Treat
access_token/refresh_tokenas opaque strings — don't attempt to parse them; call/api/whoami/if you need to inspect one from outside QDBAuth. - Refresh tokens rotate on every use — persist the new one from each
/api/token/refresh/response, not the one you sent. - Call
/api/token/revoke/on logout so a stolen/lingering refresh token can't be replayed later.