๐ค 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.