Skip to main content

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.

important

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:

  1. Marks the used code as consumed (Argon2id verification then immediate deletion — codes cannot be reused).
  2. Issues the full JWT pair.
  3. 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

PropertyValue
AlgorithmTOTP (RFC 6238) — SHA-1, 6 digits, 30-second period
Server librarypquerna/otp (Go)
TOTP secret storageVault-encrypted at rest (shared/crypto AES-256-GCM)
Recovery code formatXXXX-XXXX-XXXX (16 characters, uppercase alphanumeric)
Recovery code count10 per user
Recovery code hashingArgon2id (same parameters as passwords) — one-way, irreversible
Replay protectionValkey nonce cache, TTL 90 seconds — each code accepted once
MFA token TTL5 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

ScenarioHTTPtype URI suffix
Invalid TOTP code401unauthorized
TOTP code already used (replay)401unauthorized
MFA token expired401token-expired
Invalid recovery code401unauthorized
All recovery codes exhausted401unauthorized
MFA already enabled409conflict
MFA not enabled (disable attempt)400validation-error