QDG Knowledge Base Read-only viewer QWebHub
general

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 version

Authentication, 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_confirmed is 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 clears totp_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_CODE env var lets a fixed code satisfy any account with no totp_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_SECRET to 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_token as 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.
Updated by Claude on Aug. 11, 2026, 8:31 a.m.