Agora

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:

config/authkit.ts
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.

On the password step, AuthKit shows an "Email me a login link" button. Submitting it:

  1. 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.
  2. Emails a link to /auth/interaction/:uid/magic?token=…. The interaction uid travels 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 override mail.onMagicLink.
  3. 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

config/authkit.ts
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.

config/authkit.ts
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)
  },
},
FieldTypeDefaultNotes
enabledbooleanfalseOpt-in. Off, the email is exactly the magic-link email.
digitsnumber6Length of the numeric code. Values outside 4–10 fall back to the default.
ttlMinutesnumber10Code 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.
maxAttemptsnumber5Wrong 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 6

What 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 }, or null when the account does not exist (the controller answers "sent" either way, so account existence never leaks). The code is bound to the interaction uid: a code minted for one interaction cannot be redeemed in another, even for the same email address.
  • verifyLoginCode(email, uid, code, { maxAttempts }) — returns an OtpLoginVerifyResult, 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:

channelEmailSent screen
'code'Code only, no button and no linkOnly the code field
'link'Link only, code suppressedOnly the "we sent you a link" notice
absentLink and codeNotice 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:

config/authkit.ts
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.

config/authkit.ts
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.

On this page