Authentication, MFA & Token Internals
Version 2 · Added Site Client login (a third account type sharing the browser SSO flow), the My Account self-service redirect, and the temporary CMS-New TOTP test-code bypass introduced during QA testing.
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, and Site Clients — see below)
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. No return_url at all
is the direct path into QDBAuth's own dashboard instead (for Super
Admin/Admin) or the self-service My Account page (for Developer/Support
Lead/User) — see Roles, Permissions & Multi-Tenancy.
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.
Site Clients — a third account type on the same flow (2026-09-07)
login_view tries a users-table lookup first; if that misses and a
return_url resolves to a real registered site, it falls back to
site_clients.authenticate(username, password, site_id). A matched client
follows the exact same password → 2FA → SSO-token path as staff, just
through a separate helper (_verify_2fa_client in views.py) — the
distinction only matters internally (which table/session key backs the
pending login), not to the consuming app receiving the token.
Two differences from staff login:
- A client has no real email (same as a username-login staff/site-admin
account), so there is no emailed verification link — 2FA enrollment
happens on-screen, admin-supervised, via
dashboard.site_client_totp_setup_pageright after the admin creates the account. - A client with no
return_url(i.e. trying to reach the bare/login/with nowhere to be redirected back to) is always rejected as invalid — clients exist only to be sent back to their own site, never to reach the QDBAuth dashboard.
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: for a normal (email) account, happens once via the emailed
/verify-signup/link sent when an admin creates the account. For a username-login account — a site-admin staff account, or a Site Client — there's no email to send a link to, so enrollment happens on-screen instead (dashboard.site_admin_totp_setup_page/site_client_totp_setup_page): the admin is shown the QR code right after creating the account and hands the device to whoever will use it, or scans it themselves. Either way, the user scans the QR code (pyotp.TOTP(secret).provisioning_uri(...)), enters the resulting 6-digit code to confirm, then (for email accounts) 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/site_clients.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 for any account type — 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. A Site Client, belonging to exactly one site, has one secret.
- Dev-only bypass:
QDB_TOTP_TEST_CODEenv var lets a fixed code satisfy any account with nototp_secret, plus any username-login account (site-admin staff or Site Client) unconditionally, plus — as a temporary, QA-testing-only exception added 2026-09-06 and still present in code — any login (staff or client) where the resolved site name is exactly"CMS-New", regardless of account type. That last exception is marked inviews.pyfor removal once CMS-New testing is finished; see Known Limitations & Security Notes.
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, or a dash_*-prefixed session (dashboard) / self-service My Account session for a direct login with no return_url |
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. Note that renaming a
dash_* session key (as happened 2026-09-06 for dash_admin_sites →
dash_admin_organizations-style changes during the role rework) means an
already-logged-in admin's existing session simply stops matching the new
key and falls back to "no admin access" until they log in again — it never
grants anything extra, and doesn't force a hard logout either.
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. - If you check the role/permissions string returned by QDBAuth, be aware it can now be any of the 5-tier names or a site's own custom role name — see Roles, Permissions & Multi-Tenancy's 2026-09-06 heads-up.