How Versus adds an RFC 6238 time-based one-time-password second factor to sign-in — per-user authenticator enrollment, a short-lived login challenge, one-time recovery codes, and encrypted-at-rest secrets — and how it fits into the platform's identity layer.
Versus protects sign-in with a time-based one-time password (TOTP) second factor — the six-digit codes produced by an authenticator app such as Google Authenticator, 1Password, Authy, or Microsoft Authenticator. It follows RFC 6238, so any standards-compliant authenticator works; there is nothing Versus-specific to install on your phone.
MFA is a gate on an identity that has already been established, not a way of establishing one. A user first proves who they are with their normal credentials; only then, if they have a confirmed authenticator, does Versus require the second factor before issuing session tokens. This keeps MFA cleanly separated from the multi-backend authentication that verifies the first factor.
Each user can have exactly one authenticator, stored as a UserTOTPDevice record with a one-to-one relationship to their account. The device holds four things:
| Field | Purpose |
|---|---|
secret | The base32 TOTP shared secret. Encrypted at rest with the same field-level encryption every other auth secret in Versus uses. |
confirmed_at | Timestamp set when the user first proves they can generate a valid code. null means enrollment was started but never finished. |
recovery_codes_hashed | A list of salted SHA-256 hashes of the user's one-time recovery codes — never the plaintext. |
created_at | When enrollment began. |
The distinction between an enrolled and a confirmed device matters. Only a device with a non-null confirmed_at gates login. An abandoned enrollment — where the QR code was shown but the user never verified a code — never locks anyone out, because the login gate treats it as if no device exists.
When a user with a confirmed authenticator signs in, the credential exchange does not return session tokens directly. Instead it returns a short-lived, opaque challenge:
POST /api/auth/token/
Content-Type: application/json
{ "username": "operator", "password": "••••••••" }
200 OK
{ "mfa_required": true, "challenge": "eyJ1c2VyX2lkIjo…" }
The challenge is a signed token that encodes only the user's ID. It is tamper-evident (any modification invalidates the signature) and expires after five minutes — long enough to open your authenticator, short enough that a captured challenge is nearly useless. The client hands it straight back to the verify endpoint along with a live code:
POST /api/auth/totp/verify/
Content-Type: application/json
{ "challenge": "eyJ1c2VyX2lkIjo…", "code": "492013" }
200 OK
{ "access": "eyJhbGc…", "refresh": "eyJhbGc…" }
Only on a valid code does Versus mint the JWT access and refresh tokens that authorize the rest of the API. Codes are checked with a valid_window of one step, so a code from the immediately preceding or following 30-second window is still accepted — this absorbs minor clock skew between the server and your phone without meaningfully widening the attack surface.
A user with no confirmed device gets tokens straight from POST /api/auth/token/ exactly as if MFA did not exist. Sign-in is unchanged for them.
Enrollment is self-service and requires an already-authenticated session (you must be logged in to add a second factor to your own account). It is a two-call handshake.
POST /api/auth/totp/enroll/
Authorization: Bearer eyJhbGc…
200 OK
{
"secret": "JBSWY3DPEHPK3PXP",
"provisioning_uri": "otpauth://totp/Versus:operator?secret=JBSWY3DPEHPK3PXP&issuer=Versus"
}
Versus generates a fresh secret and returns an otpauth:// provisioning URI. The Security page renders this as a QR code for you to scan; the raw secret is shown alongside it so you can type it in manually if you can't scan. The issuer label defaults to Versus. At this point the device exists but is unconfirmed — it does not yet gate your login.
Enrollment deliberately refuses to overwrite a confirmed device and returns 409 Conflict if you already have MFA set up. This closes two holes: it stops any request bearing a valid access token from silently resetting the second factor, and it stops a UI that re-mounts the enroll step from wiping a working authenticator. Replacing a confirmed authenticator requires an explicit administrator-driven reset.
Scan the QR into your app, then submit the first code it produces:
POST /api/auth/totp/confirm/
Authorization: Bearer eyJhbGc…
Content-Type: application/json
{ "code": "104725" }
200 OK
{ "recovery_codes": [ "a1b2c3d4e5", "f6a7b8c9d0", … ] }
A correct code marks the device confirmed (confirmed_at is stamped). From this point on, every future login requires the second factor. The response returns a set of ten one-time recovery codes — this is the only time they are ever shown in plaintext. Versus stores only their hashes, so if you lose this list it cannot be recovered; you would need an administrator reset. Save them somewhere safe and offline.
The Security page reads a simple status endpoint to decide whether to offer Enroll or show MFA enabled:
GET /api/auth/totp/status/
Authorization: Bearer eyJhbGc…
200 OK
{ "enabled": true }
Recovery codes are the fallback when you don't have your authenticator — a lost or wiped phone. Each code is a single-use, high-entropy value; ten are issued at confirmation time. You spend one by sending it in place of a live code at the verify step:
POST /api/auth/totp/verify/
Content-Type: application/json
{ "challenge": "eyJ1c2VyX2lkIjo…", "recovery_code": "a1b2c3d4e5" }
A used code is consumed and removed from the account, so it can never be replayed. The comparison is constant-time to avoid leaking which stored hash matched. Because only salted hashes are kept, Versus can verify a recovery code you present but can never display your remaining codes — plan to re-enroll a fresh authenticator (via an admin reset) once you're down to your last few.
Whether the second factor is mandatory depends on the deployment:
In both cases the underlying UserTOTPDevice and the enroll / confirm / verify logic are the same code — an IdP-backed deployment simply reuses the same user store and authenticators rather than running a parallel MFA system.
MFA composes with the platform's other identity controls — role-based access, tenant isolation, and audited sessions — rather than replacing them. It is one layer of the secure-by-default posture, not the whole of it.