CARLOS rastrillo docs

๐Ÿค– Passkeys and second factors

rastrillo/passkey adds a WebAuthn second factor in two places: at step-up, where an assertion refreshes a stale session instead of a full re-sign-in, and at sign-in, where a verified first factor has to be completed by an assertion before a session exists.

What a passkey is allowed to do

A passkey never signs anybody in from nothing.

It upgrades an existing session's freshness, or completes a sign-in whose first factor already verified. Step-up endpoints demand a valid session โ€” stale is fine, absent is not. The sign-in pair demands a live pending half-session, which only a verified first factor mints. Either way, a stolen credential id on its own opens no door, and the primary factor stays the way an account is entered.

Wiring it

pk, err := passkey.New(passkey.Config{ /* ... */ })

Merge passkey.Schema into your boot set, serve webauthn.JS() as a static asset for the browser half, and mount the JSON endpoints behind csrf.Protect like every other mutating route:

POST /passkey/register/begin    -> {"challenge": ...}
POST /passkey/register/finish   <- register()'s result
POST /passkey/stepup/begin      -> {"challenge": ...}
POST /passkey/stepup/finish     <- authenticate()'s result
POST /passkey/signin/begin      -> {"challenge": ...}
POST /passkey/signin/finish     <- authenticate()'s result
POST /passkey/signin/recovery   <- form field "code"

Step-up

A successful step-up calls sessions.SignIn, which rotates the session with method "passkey" and a fresh AuthTime โ€” exactly what sessions.RequireFresh checks. See Sessions for the middleware.

A challenge lives two minutes: long enough for an authenticator prompt, short enough that an abandoned one is not a standing invitation. Challenges are single-use and subject-bound.

Sign-in-time 2FA: the Gate

Handlers.Gate is the SecondFactor hook both identity plugins expose:

a, err := auth.New(auth.Config{
	// ...
	SecondFactor: pk.Gate,
})

Called where the plugin would mint the session, it trades the immediate sign-in for a pending half-session โ€” a short-lived cookie plus a hashed row naming who must still assert, which opens nothing by itself โ€” and redirects to Config.ConfirmPath, your "confirm with your passkey" page.

That page runs webauthn.mjs's authenticate() against /passkey/signin/{begin,finish}. A verified assertion consumes the pending row, clears the cookie, and mints the real session with the original first-factor method plus "+passkey" โ€” "magiclink+passkey", say.

An account with no passkey passes the Gate untouched, returning (false, nil). So you can turn the Gate on for everyone and let enrollment decide who it applies to.

The gap between factors is bounded at five minutes. Miss it and you sign in again from the top.

Recovery codes

For the account whose only passkey is lost.

codes, err := pk.RegenerateRecoveryCodes(subject)

You get ten single-use codes, shown once, from a page you mount behind sessions.RequireFresh. pk.RecoveryCodesRemaining(subject) tells you how many are left, for a settings page that should nag.

SignInRecovery redeems one against the pending half-session where an assertion would have gone. It is a plain form POST with no JavaScript, deliberately: recovery is exactly the moment WebAuthn is not working, and a flow that needs a working WebAuthn stack to recover from a broken one is not a recovery flow.

A wrong code does not consume the half-session. It redirects back to ConfirmPath?recovery=failed so the user can try another. A correct one burns the code, consumes the pending session, and mints a session whose method is the first factor plus "+recovery" โ€” a marker you can use to nudge enrolling a replacement passkey.

This is sign-in only. There is no recovery step-up, and RequireFresh stays satisfiable only by an assertion or a full re-sign-in.

There is no attempt counter either. Redeeming needs a live half-session, which means the first factor already verified and is held for at most five minutes, and ten codes at 2โปโตโฐ apiece put brute force far below any practical odds inside that window.

What webauthn checks

rastrillo/webauthn is the identity half: ES256 only, and no attestation checking.

Skipping attestation is a decision. Attestation tells you which manufacturer made the authenticator, which matters for enterprise device policy and not for "is this the same key as last time". Verifying it means shipping and maintaining a root certificate store.

LegacyRPID accepts credentials minted under a previous hostname, so moving domains does not invalidate everybody's passkeys. A credential cannot be minted under the old name, only used.

Signature counters are checked and one going backwards is refused. An authenticator that never counts is allowed, because plenty do not.

webauthn/authtest is a fake authenticator, public so your own tests can drive a full ceremony without hardware.

Read this page as markdown โ€” exact, unstyled, and cheap for an agent to fetch.