Multi-Factor Authentication (TOTP)
SeptemCore MFA uses TOTP (Time-based One-Time Passwords) defined in RFC 6238. Authenticator apps — Google Authenticator, Authy, 1Password, Bitwarden — generate a fresh 6-digit code every 30 seconds. The server verifies it using the shared secret without any network round-trip to the authenticator app.
MFA Lifecycle
Enable → (Verify during login) → Disable ← optional
↑ ↓
Recovery Code Regenerate Codes
1. Enable MFA
POST /auth/mfa/enable initiates the TOTP setup. The IAM service
generates a new TOTP secret and returns a provisioning URI that
authenticator apps can parse from a QR code.
Request
POST https://api.septemcore.com/v1/auth/mfa/enable
Authorization: Bearer <access_token>
Response 200 OK
{
"secret": "JBSWY3DPEHPK3PXP",
"otpauth_uri": "otpauth://totp/SeptemCore:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=SeptemCore&algorithm=SHA1&digits=6&period=30",
"qr_code_svg": "<svg>...</svg>",
"recovery_codes": [
"AAAA-BBBB-CCCC",
"DDDD-EEEE-FFFF",
"GGGG-HHHH-IIII",
"JJJJ-KKKK-LLLL",
"MMMM-NNNN-OOOO",
"PPPP-QQQQ-RRRR",
"SSSS-TTTT-UUUU",
"VVVV-WWWW-XXXX",
"YYYY-ZZZZ-0000",
"1111-2222-3333"
]
}
MFA is NOT yet active after this call. The user must scan the QR
code and call POST /auth/mfa/verify with a valid TOTP code to confirm
the secret was saved correctly and activate MFA. This prevents lockout
due to a failed scan.
The secret and recovery_codes in the enable response are displayed
only once. The secret is stored server-side as a Vault-encrypted
value. The raw recovery codes are never stored — only their
Argon2id hashes are persisted. The user must save them before closing
the setup screen.
2. Verify — Activate MFA
POST https://api.septemcore.com/v1/auth/mfa/verify
Authorization: Bearer <access_token>
Content-Type: application/json
{
"code": "123456"
}
Response — MFA Activated
{
"mfa_enabled": true,
"recovery_codes_remaining": 10
}
This call activates MFA on the account. All subsequent logins require a TOTP code after the password step.
3. Login with MFA Enabled
When a user with MFA enabled submits correct credentials, the login endpoint returns an intermediate challenge instead of the token pair:
{
"mfa_required": true,
"mfa_token": "mfa_01j9p4mn3c00000000000000",
"mfa_type": "totp"
}
The mfa_token is a short-lived opaque token (TTL: 5 minutes). It
identifies the pending authentication session. The client must call:
POST https://api.septemcore.com/v1/auth/mfa/verify
Content-Type: application/json
{
"mfa_token": "mfa_01j9p4mn3c00000000000000",
"code": "523917"
}
On success, the IAM service issues the full JWT pair:
{
"access_token": "eyJhbGciOiJFUzI1NiJ9...",
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
"token_type": "Bearer",
"expires_in": 900,
"user": {
"id": "01j9p3kx2e00000000000000",
"mfa_enabled": true
}
}
TOTP timing window
The IAM service accepts the code for the current 30-second window and the immediately preceding window (60 seconds total). This accommodates clock skew between the user's device and the server. Each code is accepted exactly once — replay attacks are blocked by a nonce cache in Valkey (TTL 90 seconds).
4. Disable MFA
POST https://api.septemcore.com/v1/auth/mfa/disable
Authorization: Bearer <access_token>
Content-Type: application/json
{
"code": "523917",
"password": "S3cure-P@ssw0rd!"
}
Both the current TOTP code and the account password are required to disable MFA. This prevents an attacker with a stolen access token (but without physical access to the authenticator) from silently removing 2FA protection.
Response — MFA Disabled
{
"mfa_enabled": false
}
5. Recovery Codes
Recovery codes allow account access when the authenticator device is unavailable (lost, stolen, factory reset). Each user receives 10 single-use codes when MFA is enabled.
Using a recovery code
POST https://api.septemcore.com/v1/auth/recovery
Content-Type: application/json
{
"email": "[email protected]",
"recovery_code": "AAAA-BBBB-CCCC"
}
On success, the IAM service:
- Marks the used code as consumed (Argon2id verification then immediate deletion — codes cannot be reused).
- Issues the full JWT pair.
- Sends a security notification to the user's email: "A recovery code was used to access your account."
After recovery login, MFA remains enabled. The user should regenerate codes and reconfigure the authenticator immediately.
Checking remaining code count
GET https://api.septemcore.com/v1/auth/recovery-codes
Authorization: Bearer <access_token>
{
"remaining": 7
}
The endpoint returns only the count — never the code values. Codes are one-way hashed; the server cannot reverse them.
Regenerating codes
When fewer codes remain, the user should regenerate a fresh set of 10:
POST https://api.septemcore.com/v1/auth/recovery-codes/regenerate
Authorization: Bearer <access_token>
Content-Type: application/json
{
"password": "S3cure-P@ssw0rd!",
"code": "523917"
}
Both the account password and a valid TOTP code are required. On success:
{
"recovery_codes": [
"NEW1-CODE-AAAA",
"NEW2-CODE-BBBB",
"...",
"NEW10-CODEZZZZ"
]
}
All previous recovery codes are immediately invalidated. The new codes are shown once — never again. They are stored only as Argon2id hashes.
Implementation Details
| Property | Value |
|---|---|
| Algorithm | TOTP (RFC 6238) — SHA-1, 6 digits, 30-second period |
| Server library | pquerna/otp (Go) |
| TOTP secret storage | Vault-encrypted at rest (shared/crypto AES-256-GCM) |
| Recovery code format | XXXX-XXXX-XXXX (16 characters, uppercase alphanumeric) |
| Recovery code count | 10 per user |
| Recovery code hashing | Argon2id (same parameters as passwords) — one-way, irreversible |
| Replay protection | Valkey nonce cache, TTL 90 seconds — each code accepted once |
| MFA token TTL | 5 minutes (intermediate challenge after correct password) |
| Clock skew tolerance | ±30 seconds (current + previous window) |
SDK
import { useAuth } from '@platform/sdk-auth';
const { enableMFA, verifyMFA, disableMFA } = useAuth();
// Step 1 — initiate setup
const setup = await enableMFA();
// setup.otpauthUri → render QR code
// setup.recoveryCodes → show once, user must save
// Step 2 — confirm with code from authenticator app
await verifyMFA({ code: '523917' });
// MFA is now active
// Disable
await disableMFA({ code: '523917', password: 'S3cure-P@ssw0rd!' });
// During login — SDK handles the MFA challenge automatically
// if mfaRequired is true, sdk-auth triggers this internally:
const result = await kernel.auth().completeMfaChallenge({
mfaToken: 'mfa_01j9p4...',
code: userEnteredCode,
});
Error Reference
| Scenario | HTTP | type URI suffix |
|---|---|---|
| Invalid TOTP code | 401 | unauthorized |
| TOTP code already used (replay) | 401 | unauthorized |
| MFA token expired | 401 | token-expired |
| Invalid recovery code | 401 | unauthorized |
| All recovery codes exhausted | 401 | unauthorized |
| MFA already enabled | 409 | conflict |
| MFA not enabled (disable attempt) | 400 | validation-error |