MFA / TOTP
Authenticator enrollment, the login challenge, and recovery codes.
AuthKit ships opt-in TOTP multi-factor authentication: time-based one-time passwords from an authenticator app (Google Authenticator, 1Password, …), plus single-use recovery codes. A second option, WebAuthn passkeys, is available as an alternative factor — see WebAuthn / Passkeys.
Enabling MFA
MFA is driven by the optional MFA methods on the AccountStore. With lucidAccountStore,
compose the model with the withMfa mixin and set an mfaIssuer:
accountStore: lucidAccountStore(AuthUser, { mfaIssuer: 'Acme' }),
mfaIssuer: 'Acme',import { withMfa } from '@adonis-agora/authkit-server'
export default class AuthUser extends compose(BaseModel, withAuthUser(), withCredentials(), withMfa()) {}mfaIssuer is the label shown in the authenticator app. If a store omits the MFA methods,
MFA is simply off.
Enrollment (QR)
The account console (mounted by registerAuthHost) exposes enrollment under
/account/mfa:
| Route | Action |
|---|---|
GET /account/mfa | Show MFA status |
POST /account/mfa/enroll | Start enrollment — returns a TOTP secret + otpauth URI to render as a QR code |
POST /account/mfa/confirm | Confirm with a code from the app; on success MFA is activated and recovery codes are returned once |
POST /account/mfa/disable | Turn MFA off (clears secret + recovery codes) |
Under the hood these call startTotpEnrollment (generates a pending secret + keyuri
without activating), then confirmTotpEnrollment (verifies the code, activates MFA, and
generates the recovery codes).
The login challenge
When an account has MFA enabled, the interaction flow inserts a challenge step:
identifier → login (password) → mfa (TOTP code) → consentThe host posts the code to /auth/interaction/:uid/mfa, which calls verifyTotp. A user
who has lost their device can submit a recovery code instead — consumeRecoveryCode
validates and removes it (single-use). If the account has a passkey enrolled, the same step
also offers a passkey challenge as an alternative to typing a code.
Recovery codes
confirmTotpEnrollment returns N recovery codes in plaintext exactly once, at activation
time. Persisted hashed, each is single-use via consumeRecoveryCode. Enabling and
disabling MFA emit the mfa.enabled / mfa.disabled audit events.
Trusted devices
Asking for a second factor on every single sign-in from the same laptop is friction without much security payoff. Trusted devices lets a user mark the browser they just proved themselves on, so subsequent sign-ins from it skip the MFA step for a number of days.
The whole mechanism is one encrypted cookie — no new table, no new column, no migration. That constraint drove the design, and it is also its main limitation; both are spelled out below.
The cookie
import { TRUSTED_DEVICE_COOKIE } from '@adonis-agora/authkit-server'
import type { TrustedDevicePayload } from '@adonis-agora/authkit-server'
TRUSTED_DEVICE_COOKIE // 'authkit_trusted_device'When a second factor verifies — a TOTP code, a recovery code, or a passkey assertion — the
MFA challenge screen offers a "Trust this device for N days" checkbox. If it is ticked,
AuthKit writes authkit_trusted_device via response.encryptedCookie, so the payload is
encrypted and signed with the application key. It is httpOnly, sameSite: lax, and its
maxAge matches the trust window. Nothing about the trust is stored server-side.
The payload is a TrustedDevicePayload, deliberately terse because it rides in a cookie on
every request:
| Field | Meaning |
|---|---|
a | The account id the trust belongs to. |
d | An opaque, random per-device id. |
iat | Issued-at, epoch milliseconds. |
exp | Expiry, epoch milliseconds. |
Validation
On a later sign-in, once the password has verified and the account turns out to have MFA enabled, AuthKit reads the cookie back and validates it. A trust is honoured only when all of the following hold:
The payload is structurally intact — a is a string, iat and exp are numbers.
a matches the account that just authenticated. A cookie minted for one account is
worthless on another, so sharing a browser between two users does not leak a skipped factor.
exp is still in the future.
iat is at or after the account's last MFA (re)enrollment. This is the revocation lever:
re-enrolling the authenticator invalidates every previously trusted device at once,
because every outstanding cookie was issued before the new enrollment timestamp. When the
store does not track an enrollment timestamp, this check is skipped and expiry alone governs.
Reading the cookie is best-effort: a tampered or undecryptable value throws, the throw is swallowed, and the device is simply treated as untrusted. Failing closed here costs the user one MFA prompt.
A honoured trust completes the login with amr: ['pwd'] and no MFA acr, so a relying
party downstream can still tell that this particular sign-in did not exercise a second
factor.
Step-up always ignores the cookie
A step-up request — one where the client asks for
the mfaAcr in acr_values — always forces the second factor, and the trusted-device
cookie is never even consulted. The controller decides this before it reaches for the
cookie.
This has to be so. Step-up exists because the client is about to do something that warrants
proof right now — approving a payment, changing a payout account, entering an admin area.
A trusted-device cookie is evidence about a past ceremony on this browser; honouring it
would let the client believe it received a fresh factor when it received a months-old one.
Worse, an attacker sitting on a live session in an already-trusted browser is exactly the
adversary step-up is meant to stop, so a cookie that browser carries is precisely the wrong
thing to trust. The acr the id_token carries would then be a lie, and everything the
relying party builds on it becomes unsound.
Revocation
/account/security exposes a "Stop trusting this device" button. It clears the cookie
for the current browser and emits a trusted_device.revoked
audit event.
There is no per-device, server-side revocation list — that is the price of the no-migration design. A user who loses a trusted laptop cannot revoke just that device from another browser; the available lever is global, and it is re-enrolling MFA, which invalidates every trusted device at once.
Policy and the exported helpers
The policy is two values — whether the mechanism is on, and how many days a trust lasts.
resolveTrustedDevices produces them, and the login flow reads the result off the resolved
server config:
import { resolveTrustedDevices } from '@adonis-agora/authkit-server'
import type { ResolvedTrustedDevicesConfig } from '@adonis-agora/authkit-server'
const policy: ResolvedTrustedDevicesConfig = resolveTrustedDevices()
// { enabled: true, days: 30 }TrustedDevicesConfigInput — the shape defineConfig's trustedDevices key accepts —
carries no fields of its own. There is nothing infrastructural to configure: the cookie name
is a constant, and the encryption is the application key, which the framework already owns.
Declaring the key at all marks the trusted_devices runtime setting as
config-owned, which is how the admin console decides to render its card locked.
The trusted_devices runtime setting is accepted and validated by
authkit:settings:set, but the login flow reads the resolved config, not the stored
setting. Storing {"enabled":false} therefore does not turn the mechanism off. Treat the
effective policy as on, with a 30-day window.
Two more helpers are exported for hosts that mint or check the trust themselves — for instance a custom login screen that does not use the bundled interaction views:
import {
buildTrustedDevicePayload,
isTrustedDeviceValid,
resolveTrustedDevices,
TRUSTED_DEVICE_COOKIE,
} from '@adonis-agora/authkit-server'
// After your own second-factor ceremony succeeds and the user opted in:
const policy = resolveTrustedDevices()
response.encryptedCookie(TRUSTED_DEVICE_COOKIE, buildTrustedDevicePayload(accountId, policy), {
httpOnly: true,
sameSite: 'lax',
maxAge: policy.days * 24 * 60 * 60,
})
// Before deciding whether to challenge:
const trusted = isTrustedDeviceValid(request.encryptedCookie(TRUSTED_DEVICE_COOKIE), {
accountId,
mfaEnabledAt, // epoch ms of the last enrollment, or null when unknown
})buildTrustedDevicePayload generates the random device id and derives iat/exp from the
policy, so a host never has to invent them. isTrustedDeviceValid is the same predicate the
bundled flow uses, which is what keeps a custom screen from accidentally accepting a cookie
the library would have rejected.