Skip to content

16. A second factor and magic links

Sign in with a link by email, require an authenticator app of the admin role, a rule that asks for it, and proofs before sensitive actions.

Shelf members sign in with a password. This chapter adds two things a real library would want: a way in without a password (a link and a code by email), and a second factor for the people who can do the most damage, the operators.

It builds on chapter 10’s email pipeline: the link arrives in the Mailbox of the Admin view, like every other message.

Add to config/shelf/realm.yaml:

auth:
  strategies:
    magic_link: [magic_link]     # a way to sign in: a link (and a code) by email
  magic_link:
    enabled: true
  sign_in:
    app_url: http://localhost:8080/   # within email.allowed_redirects
  • strategies.magic_link is a second way to sign in, next to the password strategy of chapter 2. A strategy is a named list of methods; the app says which one it uses.
  • auth.magic_link.enabled turns on POST /_auth/magic-link. It needs the email: section of chapter 10 and a BACKD_SECRETS_KEY on the instance (the tutorial’s compose.yaml already sets one): backd refuses to start without it, because it keeps the address of a request sealed until the email is sent.
  • auth.sign_in.app_url is where the link ends when the app didn’t say: the app itself. It must be within the email.allowed_redirects of chapter 10.
  • Sign-up stays invite-only. auth.magic_link.signup would let an unknown address make an account by link, and only a realm with signup: open allows it. Shelf keeps signup: invite: a link signs in members who exist, and a stranger asking for one is told nothing (the answer is the same for every address) and gets a harmless “no account” email.

Restart the stack, Sign in, type an address and press Email me a link. Open the Mailbox in the Admin view as the operator, or read it with the API as in chapter 10: the message has a link and a 6-digit code. Either works, once, for ten minutes:

  • the link, opened in the same browser, lands on a page of backd with a button, and the button sends the browser back to the app, which finishes the sign-in by itself;
  • the code, typed in the app, works on any device that can read the mail.

The app does it in a few lines:

await backd.auth.magicLink.request(email)          // always answers the same
const outcome = await backd.auth.magicLink.verifyCode(code)   // a session, or a second step
// and, when the link came back to this page:
const outcome = await backd.auth.ready

The app you downloaded in the starter is the finished one, so the Email me a link form and the code field are already there; they only needed the realm to say yes. (If you made the starter before this chapter existed, fetch app/app.js, app/index.html and client/oauth.js again: the client module the app imports gained oauth.js with sign-in by providers.)

A second factor for the operators

Passwords leak. The operator role can read the audit feed, invite members and make API keys, so give it more than a password. Add this to config/shelf/realm.yaml too:

auth:
  strategies:
    password_totp: [password, totp]
    magic_link_totp: [magic_link, totp]
roles:
  admin:
    strategies: [password_totp, magic_link_totp]

Two strategies add the authenticator app as a second step (totp after the first method). The admin role lists the strategies it accepts, and both of them end with the authenticator app: a password alone is not enough for the operator.

Restart backd. In the app, the sign-in form has an I am an operator (password and authenticator code) box: it signs in with the password_totp strategy instead of password. The first time, since the operator has no second factor yet, the app shows a QR code and the secret. Scan it with an authenticator app (any TOTP app), type the first code it shows and Turn it on and sign in. The Account page then shows the recovery codes, once: each works one time if the phone is lost. Save them.

From then on that strategy asks for the code after the password, and backd remembers how the session was started: user.mfa is true in rules for it, user.strategy is password_totp, and ctx.user.mfa and ctx.user.strategy say the same in functions.

What if the operator forgets the box and signs in with password alone? They get a session, but the admin role is not active in it: the sign-in answer says roles_inactive: ["admin"], the Admin view refuses them, and the app can offer the stronger way in. That is the point of listing strategies on a role: the password alone never opens the operator’s powers.

const result = await backd.auth.signIn({ email, password, strategy: 'password_totp' })
if (result.mfaRequired) {
  const session = await backd.auth.verifyMfa(result.challenge, 'totp', code)
} else if (result.enrollmentRequired) {
  const setup = await backd.auth.mfa.setup({ challenge: result.challenge })   // the QR and the secret
  const done = await backd.auth.mfa.confirm({ challenge: result.challenge, setupId: setup.setup_id, code })
}

Members can also set an authenticator up for themselves on the Account page (Two-step sign-in), but only a strategy that lists totp ever asks for it: a member who signs in with password is not asked for a code, even with an authenticator.

Passkeys

A passkey is a key pair the person’s device keeps (behind the screen lock, a fingerprint or a PIN), a password manager or a security key. Nothing is typed, nothing can be phished: the browser signs a challenge for localhost only. Add this to config/shelf/realm.yaml as well:

auth:
  strategies:
    passkey: [passkey]
  passkeys:
    enabled: true

The relying party id and the origin come from auth.sign_in.app_url (localhost, which is allowed over http only while BACKD_DEV is set). Restart backd. A member adds a passkey on the Account page (Passkeys → Add a passkey, after proving who they are once more), and from then on the sign-in form offers Sign in with a passkey; browsers that support it also list the passkey in the email field’s autofill, so one tap signs the member in.

// the button
const result = await backd.auth.passkeys.signIn({ strategy: 'passkey' })
// the email field: wait for the person to pick a passkey from the autofill
const ac = new AbortController()
backd.auth.passkeys.signIn({ strategy: 'passkey', conditional: true, signal: ac.signal })
// the account page
await backd.auth.passkeys.register({ name: 'Laptop' })      // asks for a fresh proof first
const mine = await backd.auth.passkeys.list()

The input needs autocomplete="username webauthn" for the autofill to show passkeys. A strategy can also use passkey as a second step, as the page on strategies explains.

A rule that asks for it

The import log (chapter 11) is only for operators. Make the rule say and on a session that used the second factor:

examples/config/shelf/main/imports/collection.yaml

rules:
  # Only the import function writes events. Admins may read the list (it's a
  # record of what the feed delivered); nobody else needs it. Chapter 16: and
  # only on a session that went through the second factor (`user.mfa`), so a
  # stolen password alone doesn't open it.
  read: user != nil && hasRole(user, 'admin') && user.mfa

Restart backd. The operator, signed in with a code, still reads the list; a curator gets 403 as before. The point of the extra && user.mfa is a session that can’t have it: an API key, or a member session of a person who is also an admin by role but signed in some other way. Rules decide on how the person got in, not only on who they are.

Proving it again before something sensitive

Changing the password or the address, deleting the account, turning the second factor off, or making an API key as a person all ask for a fresh proof: 401 reauth_required until the user shows the strongest thing they have again.

The app asks only when the server wants it:

await backd.auth.withProof(
  (methods) => askTheUser(methods),            // methods: ['totp', 'recovery_code'] or ['password']
  () => backd.auth.deleteAccount(),
)

withProof makes the call, and on reauth_required asks the person, sends the proof to POST /_auth/reauth and repeats the call. The proof lives for five minutes (auth.reauth.ttl in the realm), so one prompt covers a settings page. See Re-authentication.

You should see

  • Sign in → Email me a link puts a message with a link and a code in the Mailbox; either one signs the member in, and then neither works again.
  • The operator’s next sign-in shows a QR code. After the first code, the Account page lists the recovery codes, and the following sign-ins with the operator box ticked ask for a code. Without the box, the operator is signed in with no admin role.
  • Account → Two-step sign-in lets a member turn it on and off; the operator’s Turn it off is missing (the role requires it).
  • Delete my account and the email and password changes ask for a code (or the password, without a second factor) before they go.
  • Account → Passkeys → Add a passkey makes one, and Sign out → Sign in with a passkey signs the member in without typing anything.
  • The import log answers 200 to the operator and 403 to a curator.

The tour of the tutorial (make shelf-tour) checks all of it with the same client library, including the operator’s enrolment through a computed TOTP code. Read Multi-factor authentication and Magic links for the whole picture: limits, lockouts, break-glass resets and the recovery flow.

Next: chapter 17 — terms and consents.

Edit this page · Last updated 13 Nov 2026