Agora

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:

config/authkit.ts
accountStore: lucidAccountStore(AuthUser, { mfaIssuer: 'Acme' }),
mfaIssuer: 'Acme',
app/models/auth_user.ts
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:

RouteAction
GET /account/mfaShow MFA status
POST /account/mfa/enrollStart enrollment — returns a TOTP secret + otpauth URI to render as a QR code
POST /account/mfa/confirmConfirm with a code from the app; on success MFA is activated and recovery codes are returned once
POST /account/mfa/disableTurn 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) → consent

The 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.

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:

FieldMeaning
aThe account id the trust belongs to.
dAn opaque, random per-device id.
iatIssued-at, epoch milliseconds.
expExpiry, 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.

A step-up request — one where the client asks for the mfaAcr in acr_valuesalways 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.

On this page