Verify TOTP & Activate 2FA
Verify the 6-digit TOTP code against the previously stored secret. On success, flip 2FA on, generate one-time backup codes, and revoke all existing sessions.
POST /api/v1/auth/2fa/verify — 🔑 Bearer · Rate limit: 5 req / hour
Verifies the 6-digit code against the secret stored by POST /auth/2fa/setup (or /setup-init). On success: flips User.twoFactorEnabled = true, generates auth.backup_code_count one-time backup codes, and revokes all existing sessions for the user (a re-login is required, this time with 2FA).
Backup codes are returned ONCE. Display them and prompt the user to download / print / save to a password manager. The server only stores bcrypt hashes — there is no way to fetch them again. To rotate, call POST /auth/2fa/backup-codes/regenerate.
All sessions for this user are revoked on success — including the one calling this endpoint. The next request with the old access token will succeed only until the JWT expires (auth.access_token_ttl_seconds); the refresh cookie is dead immediately. Plan to redirect to a 2FA-enabled login flow right after handling the response.
Request
Body — VerifyTotpDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
code | string | ✓ | exactly 6 chars (Length(6, 6)) | 6-digit TOTP code from the authenticator |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | JWT from POST /auth/login |
Response
200 OK — ApiResponseOf<BackupCodesResponseDto>
{
"success": true,
"data": {
"backupCodes": [
"ABCD-2345",
"EFGH-6789",
"JKLM-2345",
"NPQR-6789",
"STUV-2345",
"WXYZ-6789",
"23AB-CDEF",
"45GH-JKLM",
"67NP-QRST",
"89UV-WXYZ"
]
}
}| Field | Type | Notes |
|---|---|---|
backupCodes | string[] | One-time codes in XXXX-XXXX format (alphabet skips 0/O/1/I to avoid confusion). Count = auth.backup_code_count (admin-managed config; default 10). Server stores bcrypt hashes only — these strings are never recoverable. |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | auth.2fa.setup_not_initiated | No User.twoFactorSecret — call /setup first |
400 | auth.2fa.already_enabled | User.twoFactorEnabled is already true |
400 | auth.2fa.invalid_code | TOTP code doesn't match (within auth.totp_window drift tolerance) |
401 | (guard) | Missing / invalid bearer token |
429 | (throttle) | Rate limit exceeded (5 req/hour) |
Side effects
- Lookup
User; asserttwoFactorSecret IS NOT NULLANDtwoFactorEnabled = false. - Decrypt
User.twoFactorSecret(decryptPii— AES-256-GCM; handles legacy plaintext too). otplib.verify()against decrypted secret withepochTolerance = auth.totp_window(default 1 step = ±30s).- On success, inside one transaction:
- Generate
auth.backup_code_countcodes (XXXX-XXXXformat, no0/O/1/I). - Bcrypt-hash each code (
auth.salt_rounds) and insert intoBackupCodetable. - Set
User.twoFactorEnabled = true. - Update all
Sessionrows for this user →revoked = true,revokedAt = now().
- Generate
- Audit log:
[2fa] Activated for user {userId}.
Code samples
curl -X POST https://api.bio.re/api/v1/auth/2fa/verify \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"code": "123456"}'type BackupCodes = { backupCodes: string[] };
async function verifyTwoFactor(accessToken: string, code: string): Promise<BackupCodes> {
const res = await fetch('https://api.bio.re/api/v1/auth/2fa/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? '2FA verify failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useVerifyTwoFactor() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (code: string) => {
const res = await fetch('/api/v1/auth/2fa/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? '2FA verify failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as BackupCodes;
},
onSuccess: () => {
// Sessions revoked server-side — invalidate identity + force re-auth flow
qc.invalidateQueries({ queryKey: ['auth', 'me'] });
},
});
}Try it
Authorization
bearer In: header
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
curl -X POST "https://loading/api/v1/auth/2fa/verify" \ -H "Content-Type: application/json" \ -d '{ "code": "123456" }'{
"success": true,
"data": {
"backupCodes": [
"ABC12345",
"DEF67890"
]
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/auth/two-factor.controller.ts | 52–62 (verifyAndActivate) |
| DTO (request) | apps/api-core/src/modules/auth/dto/two-factor.dto.ts | 4–7 (VerifyTotpDto) |
| DTO (response) | apps/api-core/src/modules/auth/dto/response.dto.ts | 126–129 (BackupCodesResponseDto) |
| Service | apps/api-core/src/modules/auth/two-factor.service.ts | 55–101 (verifyAndActivate) |
| Prisma models | packages/prisma/prisma/schema.prisma | User.twoFactorEnabled, BackupCode, Session.revoked |
Init 2FA Setup (Stable Shape)
Alias of POST /auth/2fa/setup with an explicit recoveryCodes:null contract — kept thin so the frontend type stays stable across setup → verify.
Disable 2FA
Turn 2FA off after re-confirming the account password. Wipes the TOTP secret, deletes all backup codes, and revokes all sessions.