Passwordless
Magic-link login, six-digit email codes, passwordless sign-up, and passkey-first sign-in — without a password.
AuthKit offers three opt-in passwordless paths, all off by default and all configured under
passwordless:
passwordless: {
magicLink: true, // "Email me a login link"
passkeyFirst: true, // "Sign in with a passkey" before the password
signup: true, // public sign-up with no password at all
},Each path also requires the matching capability on the AccountStore. With
lucidAccountStore, magic link (and therefore passwordless sign-up) works out of the box —
it reuses the password-reset columns, so there is no migration. Passkey-first requires
the WebAuthn capability (withWebauthnCredential). If a capability is
missing, the corresponding option is simply hidden rather than failing at runtime.
On top of magic link, login.otp adds a typed six-digit code to the very same email —
see Email login codes below.
Magic link
On the password step, AuthKit shows an "Email me a login link" button. Submitting it:
- Issues a short-lived, single-use login token that lives for 15 minutes. The Lucid store keeps it in the password-reset columns, but a login token and a password-reset token are distinct things: consuming a login token authenticates the user and never touches the password, and a login token can never be redeemed as a password reset.
- Emails a link to
/auth/interaction/:uid/magic?token=…. The interactionuidtravels in the link, so opening it resumes the original authorize request — the user lands back at the client they started from, not on a generic dashboard. The default mailer is used unless you overridemail.onMagicLink. - Always renders "If the account exists, we sent you a login link" — regardless of whether the account exists, to avoid account enumeration.
Opening the link consumes the token and completes the login with amr: ['email']. A bogus
or expired token sends the user back to the start of the login. The request is throttled by
the same login rate-limit bucket, and emits login.magic_link_sent /
login.failure audit events.
Emailed links are built from the resolved issuer's origin, never from the request's
Host header. See Security — the mail.origin escape hatch
for why that distinction matters.
Custom mailer
mail: {
onMagicLink: async ({ email, magicUrl, token, code, channel }) => {
// send with your own transport
},
},code and channel are only populated when email login codes are enabled; the
next section explains both.
Email login codes (OTP)
Some users cannot click a link — the mail client strips it, the link opens in the wrong
browser, or they read email on a different device than the one they are signing in on.
login.otp solves that by putting a short typed code in the very same email as the
link. Both halves complete the same OIDC interaction, so whichever one the user reaches
for, they end up back at the client that sent them.
login: {
otp: {
enabled: true, // default: false
digits: 6, // default: 6 (accepted range: 4–10)
ttlMinutes: 10, // default: 10 (minimum: 1)
maxAttempts: 5, // default: 5 (minimum: 1)
},
},| Field | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | false | Opt-in. Off, the email is exactly the magic-link email. |
digits | number | 6 | Length of the numeric code. Values outside 4–10 fall back to the default. |
ttlMinutes | number | 10 | Code lifetime. Deliberately shorter than the link's 15 minutes — a guessable secret should live less long than an unguessable one. Values below 1 fall back to the default. |
maxAttempts | number | 5 | Wrong attempts before the code is disabled. Values below 1 fall back to the default. |
OTP_LOGIN_DEFAULTS is exported, so the same numbers are available to a host that renders
its own screens:
import { OTP_LOGIN_DEFAULTS, resolveOtpLoginConfig } from '@adonis-agora/authkit-server'
OTP_LOGIN_DEFAULTS // { enabled: false, digits: 6, ttlMinutes: 10, maxAttempts: 5 }
// Same normalisation the server applies, including the sanity clamps.
resolveOtpLoginConfig({ digits: 99 }) // digits falls back to 6What the store must implement
The feature is capability-probed: it is available only when login.otp.enabled is true
and the account store implements OtpLoginCapability. Either half missing and the flow
degrades silently to plain magic link — the code field never renders, and no code is
generated.
import type { OtpLoginCapability, OtpLoginVerifyResult } from '@adonis-agora/authkit-server'OtpLoginCapability has exactly two methods:
issueMagicLinkWithCode(email, uid, { digits, ttlMinutes })— issues the link token and the code in a single shot, returning{ token, code, account }, ornullwhen the account does not exist (the controller answers "sent" either way, so account existence never leaks). The code is bound to the interactionuid: a code minted for one interaction cannot be redeemed in another, even for the same email address.verifyLoginCode(email, uid, code, { maxAttempts })— returns anOtpLoginVerifyResult, one of{ status: 'ok', account },{ status: 'invalid' },{ status: 'locked' },{ status: 'expired' }, or{ status: 'no_code' }.
lucidAccountStore implements both. Its key property is that the code and the link are
jointly single-use: redeeming either one destroys the other. A user who types the code
cannot then have the link in their inbox reused, and vice-versa.
The attempt counter
A 256-bit link token is not guessable; a six-digit code is. The code therefore carries its own persisted attempt counter, incremented on every wrong guess and stored alongside the code rather than in a rate limiter. That choice is deliberate: a limiter is an optional peer, and a lockout that silently becomes a no-op when the peer is absent is worthless in front of a one-in-a-million secret. Because the counter is persisted, the lockout is fail-closed.
When the counter reaches maxAttempts, the code is disabled but the link stays valid —
the user still has a working way in from the same email, and the screen says so. Verification
runs its checks in a fixed order — lockout first, then the code's TTL, then a constant-time
comparison — so an exhausted counter short-circuits before any comparison happens at all.
On top of the counter, POST /auth/interaction/:uid/otp-verify sits behind its own
per-IP rate-limit bucket (otpLogin, 5 requests/minute by default), tighter than the
login bucket. The counter is the hard guarantee; the bucket is the extra layer.
Choosing the channel
Some products would rather not show both a link and a code in one email. The choose-first selector lets the user pick how they want to sign in before anything is sent: a typed code or a clicked link.
The choice travels as a channel field on the POST /auth/interaction/:uid/magic body,
with two accepted values, code and link. Anything else — absent, empty, unrecognised —
means "no choice", and both halves are shown.
channel is purely a surface decision. It never changes what is issued: the link and
the code are always minted together when OTP login is on. It decides two things only —
which sub-view the sent screen shows, and what the email renders:
channel | Sent screen | |
|---|---|---|
'code' | Code only, no button and no link | Only the code field |
'link' | Link only, code suppressed | Only the "we sent you a link" notice |
| absent | Link and code | Notice and code field |
The value reaches your mail hook untouched, which is the whole point — a host that renders its own selector can render a code-only or link-only email:
mail: {
onMagicLink: async ({ email, magicUrl, token, code, channel }) => {
if (channel === 'code' && code) {
return sendCodeOnlyEmail(email, code)
}
if (channel === 'link') {
return sendLinkOnlyEmail(email, magicUrl)
}
return sendBothEmail(email, magicUrl, code)
},
},The selector degrades cleanly. If a request asks for channel: 'code' while OTP login is
off, there is no code to show, so the default mailer falls back to the link email rather
than sending an empty message.
The bundled Edge login screen posts no channel, so it shows both. The code form it
renders posts channel=code back, which keeps the code-only sub-view in place across a
failed attempt instead of bouncing the user to a screen full of options they already
rejected.
Pure helpers
Three exports let a host that renders its own screens — or writes its own account store — reuse the library's decisions instead of re-deriving them:
import {
generateOtpCode,
evaluateLoginOtp,
OTP_LOGIN_DEFAULTS,
} from '@adonis-agora/authkit-server'
import type { OtpVerifyOutcome } from '@adonis-agora/authkit-server'
// Uniform, zero-bias digits: rejection sampling, never `% 10`, which would
// over-represent the low digits and shrink the real search space.
const code = generateOtpCode(OTP_LOGIN_DEFAULTS.digits) // e.g. '048213'
// The verification state machine, with the check ordering baked in.
const outcome: OtpVerifyOutcome = evaluateLoginOtp({
parsed: null, // nothing pending for this interaction
uid: interactionUid,
code: submittedCode,
nowMs: Date.now(),
maxAttempts: OTP_LOGIN_DEFAULTS.maxAttempts,
}).result // → 'no_code'evaluateLoginOtp is the function lucidAccountStore.verifyLoginCode delegates to. It is
pure: given the pending code state it returns the OtpVerifyOutcome and what the store
should persist next — discard the pending code on success, bump the counter on a wrong
guess, disable the code on the last one. Keeping that logic in one pure function is what makes the ordering
guarantee testable rather than aspirational.
A store that stores pending codes its own way should implement OtpLoginCapability
directly — that interface is the supported extension point, and it is what the login flow
probes for.
Audit trail
Requesting the email emits login.magic_link_sent and login.otp_sent. Verification
emits login.otp_verified, login.otp_failed, or login.otp_invalidated — see
Events & Webhooks for the exact payloads.
Passwordless sign-up
passwordless.signup opens public registration with no password at all. The sign-up
form asks for an email address and a full name; nothing else. It requires the store to
implement MagicLinkCapability — without it the flag has no effect and the ordinary
password sign-up keeps working.
passwordless: {
magicLink: true,
signup: true,
},What happens on submit:
The email and name are validated (the name must be 2–255 characters).
If no account exists for that address, one is created with a random, unusable password.
Nobody — including the user — ever knows it; it exists only so the account row is complete.
This mirrors how accounts created from a social identity are stored. A signup audit event
is recorded.
A magic link is issued and emailed, exactly as in the login flow (your mail.onMagicLink
hook wins over the default mailer when present).
The screen always answers "we sent you a link", whether the account was just created or already existed. Sign-up is a public endpoint, so an honest "that address is taken" would be a free account-enumeration oracle.
Opening the link completes the login through the ordinary magic-link path, with
amr: ['email']. Sign-up and first sign-in are therefore the same click.
Interaction with registration
Passwordless sign-up is still sign-up, so it sits behind the same gate. When the
registration setting — or its static fallback
registration.enabled — is off, both GET and POST of the sign-up route are refused with
the "registration is closed" screen, before the passwordless branch is ever reached.
Privileged paths (an admin creating a user, an organization invitation) are unaffected.
Interaction with require_verified_email
This one deserves care. Consuming a magic link authenticates the user; it does not mark
the address as verified. So if login.requireVerifiedEmail
(or the require_verified_email runtime setting) is on, an account created through
passwordless sign-up cannot complete a login: the link is valid, but the verified-email gate
rejects the session and the user is told to verify their address first.
Pair passwordless.signup with requireVerifiedEmail only if your flow also sends a
genuine email-verification link. Otherwise the two settings combine into a sign-up that
can never sign in.
Passkey-first
When passkeyFirst is enabled and the looked-up account has at least one passkey, the
password step also offers "Sign in with a passkey" — before a password is entered.
It reuses the existing passkey ceremony endpoints from the MFA step
(/auth/interaction/:uid/passkey/options and /passkey/verify), now allowed from the
password stage. A successful assertion completes the login with amr: ['webauthn'] and
counts as the strong factor — no password and no separate MFA step are required.
Guard rails: the button only appears when the store supports passkeys and the account has at least one registered credential.