Two-Factor Authentication (TOTP)
Gate Identity supports TOTP-based MFA (Time-based One-Time Passwords, RFC 6238). When enabled, your end-users can link an authenticator app to their account and will be prompted for a 6-digit code at every login.
Backup codes (lookup secrets) are also supported — each user gets a set of single-use codes they can store offline in case they lose access to their authenticator.
Enabling MFA for Your Tenant
MFA is opt-in per tenant and disabled by default.
Via the Dashboard
Go to Gate Identity → Security and toggle Two-Factor Authentication (TOTP) on, then click Save Changes.
Via the API
curl -X PUT https://api.vernesoft.com/v1/gate/settings/security \
-H 'Authorization: Bearer vrn_9f8a7...' \
-H 'Content-Type: application/json' \
-d '{ "passwordless_enabled": false, "mfa_enabled": true }'
Response (200 OK)
{ "status": "ok" }
Note: Both
passwordless_enabledandmfa_enabledmust be included in the PUT body. Use the current values fromGET /v1/gate/settings/securityif you only want to change one.
Reading Current Security Settings
curl https://api.vernesoft.com/v1/gate/settings/security \
-H 'Authorization: Bearer vrn_9f8a7...'
How It Works
1. User sets up TOTP (via your settings page)
After a user logs in, your app initiates a settings flow and the response will contain group: "totp" nodes — a QR code URI and a manual entry key. The user scans this with their authenticator app.
Your app Edge Gateway Kratos
│ │ │
│── GET /v1/gate/auth/settings ──────▶ │
│ X-Session-Token: <token> │
│◀── flow JSON (with totp nodes) ───────-│
│ │ │
│── POST /v1/gate/auth/settings/submit ─▶│
│ { "method": "totp", │
│ "totp_code": "123456" } │
│◀── 200 TOTP linked ───────────────────-│
The TOTP nodes include:
group: "totp",attributes.id: "totp_secret_key"— the secret (also as QR code URI inmeta.label)group: "totp",attributes.name: "totp_code"— the verification inputgroup: "lookup_secret"nodes — the one-time backup codes
2. Login with TOTP
A password submit completes the login and returns a session — one at assurance level aal1. The second factor is a separate step, asked for by name, carrying the session the password step issued. Nothing challenges the user on your behalf, so a login that stops after the password is a login that skipped the second factor.
Your app Edge Gateway Kratos
│ │ │
│── GET /v1/gate/auth/login ───────────▶ │
│◀── flow + "mfa_required": true ───────-│
│ │ │
│ [user submits password to flow.ui.action]
│◀── 200: session_token, aal1 ──────────-│
│ │ │
│── GET /v1/gate/auth/login?aal=aal2 ──▶ │
│ X-Session-Token: <session_token> │
│◀── flow with totp + lookup_secret ────-│
│ │ │
│ [user enters code, submits to flow.ui.action]
│◀── 200: session, aal2 ────────────────-│
Note: The step-up flow needs the user's
X-Session-Token— it refreshes that session rather than starting a new login. Submit{ "method": "totp", "totp_code": "123456" }, or{ "method": "lookup_secret", "lookup_secret": "<backup code>" }, to the flow's ownui.actionwith the same session token.
The session must be one of your tenant's end users'. A token that is expired, or that belongs to another tenant's user or to a dashboard account, is answered 401 unauthorized ("That session is not active.") — the same answer for all of them — and no flow is started. The same holds for the settings flow and for GET /v1/gate/auth/session. A person's step-up can only be finished through the tenant the person belongs to. The first-factor session of a person who has already enrolled an authenticator is one of them — see how the gateway knows whose it is.
If the step-up flow comes back with no totp or lookup_secret nodes, the user has not enrolled yet — send them through the settings flow above first.
3. Checking a session is good enough
GET /v1/gate/auth/session answers whether a session meets what your tenant requires, so your backend does not have to reason about assurance levels:
curl https://api.vernesoft.com/v1/gate/auth/session \
-H 'Authorization: Bearer vrn_9f8a7...' \
-H 'X-Session-Token: <kratos_session_token>'
With MFA switched off, mfa_required is false and mfa_satisfied is true for any live session — the field means "good enough for this tenant", not "a second factor happened". That is the one call to gate your own routes on.
The mfa_required: true field in the login flow response signals that your tenant has MFA enabled, so your UI can show appropriate messaging.
A first-factor session of a user who has enrolled
A first-factor (aal1) session of a user who has already enrolled an authenticator cannot be read until the second factor is given — Kratos will not say whose it is. GET /v1/gate/auth/session answers it with 403 mfa_required, whether or not your tenant has switched MFA on, because Kratos demands the second factor of that person regardless:
{
"error": {
"code": "mfa_required",
"message": "This person has a second factor and has not given it for this session. Start a step-up flow at GET /v1/gate/auth/login?aal=aal2 with this session token and submit the user's authenticator code or one of their backup codes.",
"required_aal": "aal2",
"current_aal": "aal1"
}
}
The next step is the step-up flow with that session token (GET /v1/gate/auth/login?aal=aal2, above), or the settings flow, to enrol.
The hint is only for the user's own tenant. The gateway knows whose such a session is because it wrote it down when it handed the session out — a login (password, one-time code or the second factor itself), a registration, a recovery or a social-login exchange — as the SHA-256 of the token, for as long as the session lives. A key of any other tenant that presents the token, a token the gateway did not hand out (a session obtained straight from Kratos, or one whose record has expired), and a token that does not exist are all answered the same way: 401 unauthorized, "That session is not active.", for GET /v1/gate/auth/session and for GET /v1/gate/auth/login?aal=aal2 alike. Nothing in the answer says that a token is real or that its owner has an authenticator. If the person's session is answered 401 although they only just signed in, sign them in again through your Gate key. If the gateway cannot read its record at that moment the answer is 502 upstream_unavailable — retry.
A session that has been revoked, or whose user has been deactivated or deleted, is 401 like any dead session; a deactivated user who is switched on again gets their sessions back, and the 403 with them.
Integration Guide
// ─── 1. Render TOTP setup in user settings ────────────────────────────────────
async function loadTotpSetup(sessionToken) {
// Fetch settings flow from your backend
const flow = await fetch('/account/settings/flow').then(r => r.json())
// Find TOTP nodes
const totpSecret = flow.ui.nodes.find(
n => n.group === 'totp' && n.attributes?.id === 'totp_secret_key'
)
const totpInput = flow.ui.nodes.find(
n => n.group === 'totp' && n.attributes?.name === 'totp_code'
)
const lookupNodes = flow.ui.nodes.filter(n => n.group === 'lookup_secret')
if (!totpSecret) {
// TOTP not available — mfa_enabled is false for this tenant
return null
}
return {
qrUri: totpSecret.meta?.label?.text, // otpauth://totp/... — render as QR
secretKey: totpSecret.attributes.value, // manual entry fallback
backupCodes: lookupNodes
.filter(n => n.attributes?.text?.text)
.map(n => n.attributes.text.text),
flowId: flow.id,
}
}
// ─── 2. Verify and link the authenticator ────────────────────────────────────
async function linkTotp(flowId, totpCode) {
const result = await fetch(`/account/settings/password?flow=${flowId}`, {
// Note: reuse your settings/submit proxy route — just send method: "totp"
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: 'totp', totp_code: totpCode }),
}).then(r => r.json())
if (result.ui?.messages?.some(m => m.type === 'error')) {
throw new Error(result.ui.messages.map(m => m.text).join(', '))
}
return result
}
// ─── 3. Handle TOTP challenge during login ────────────────────────────────────
async function handleLoginFlow(flow, email, password) {
// Phase 1: the password submit COMPLETES the login and returns a session.
// It is at aal1 — nothing has asked for a second factor yet.
const phase1 = await fetch(flow.ui.action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: 'password', identifier: email, password }),
}).then(r => r.json())
const sessionToken = phase1.session_token
if (!sessionToken) return { ok: false, flow: phase1 }
// Phase 2: ask whether that session is good enough for this tenant.
const check = await fetch('/api/gate/session', { // your proxy for
headers: { 'X-Session-Token': sessionToken }, // /v1/gate/auth/session
})
if (check.ok) return { ok: true, sessionToken } // nothing more owed
const { error } = await check.json()
if (error?.code !== 'mfa_required') throw new Error(error?.message)
// Phase 3: start the second-factor step for THAT session.
const stepUp = await fetch('/api/gate/login?aal=aal2', { // your proxy for
headers: { 'X-Session-Token': sessionToken }, // /v1/gate/auth/login
}).then(r => r.json())
const enrolled = stepUp.ui?.nodes?.some(n => n.group === 'totp')
// No totp nodes means this user has never enrolled — send them to settings.
return { ok: false, requiresTotp: enrolled, requiresEnrolment: !enrolled, flow: stepUp, sessionToken }
}
// Submits the authenticator code — or a backup code, when the phone is gone.
async function submitSecondFactor(flow, sessionToken, { totpCode, backupCode }) {
const body = totpCode
? { method: 'totp', totp_code: totpCode }
: { method: 'lookup_secret', lookup_secret: backupCode }
const result = await fetch(flow.ui.action, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Session-Token': sessionToken },
body: JSON.stringify(body),
}).then(r => r.json())
// Same session, now at aal2.
return result.session
}
mfa_required Flag in Login Flow
When your tenant has MFA enabled, GET /v1/gate/auth/login includes "mfa_required": true in the response. Use this to:
- Show "You'll need your authenticator app" messaging before the user starts logging in
- Render a TOTP input field after the password step
{
"id": "flow_abc123",
"mfa_required": true,
"ui": { ... }
}
Backup Codes
When a user sets up TOTP, Kratos also generates backup codes (group: "lookup_secret" nodes in the settings flow). These are single-use codes the user can store securely and use instead of their authenticator if they lose their device.
Backup codes can be regenerated at any time through the settings flow by submitting { "method": "lookup_secret", "lookup_secret_regenerate": true }.
Disabling MFA
To disable MFA for your tenant, set mfa_enabled: false via the Dashboard or the API. GET /v1/gate/auth/session stops requiring aal2 immediately, and TOTP setup disappears from your users' settings flow.
Before you switch it off: users who already enrolled keep their authenticator, and Kratos still demands it from them before it will accept a settings change — including the change that would remove it. Since disabling MFA also hides the
totpnodes they would use to unlink it, ask enrolled users to remove their authenticator first, while the setting is still on.
Error Handling
| HTTP status | Meaning |
|---|---|
422 with totp_code error | Invalid or expired TOTP code — user should try again |
422 with lookup_secret error | Invalid backup code |
403 on settings submit | Session expired — user must log in again |