CARLOS rastrillo docs

๐Ÿค– passkey

amadan.net/rastrillo/rastrillo/passkey

A WebAuthn second factor on two seams: step-up, where an assertion refreshes a stale session instead of a full re-sign-in, and sign-in, where a verified first factor must be completed by an assertion before a session exists.

Passkeys and second factors is the guide.

The trust boundary

A passkey signs someone in on its own only through the discover endpoints, and only by proving it holds a key registered here: a stolen credential id on its own opens no door. 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.

New, Config and Schema

func New(cfg Config) (*Handlers, error)

Merge passkey.Schema into your boot set, and serve webauthn.JS() as a static asset for the browser half.

Credentials are public material: a public key verifies signatures and nothing else. Challenges are single-use rows consumed by DELETE ... RETURNING.

The endpoints

Mount them behind csrf.Protect like every other mutating route:

Route Handler
POST /passkey/register/begin Handlers.RegisterBegin
POST /passkey/register/finish Handlers.RegisterFinish
POST /passkey/stepup/begin Handlers.StepUpBegin
POST /passkey/stepup/finish Handlers.StepUpFinish
POST /passkey/signin/begin Handlers.SignInBegin
POST /passkey/signin/finish Handlers.SignInFinish
POST /passkey/discover/begin Handlers.DiscoverBegin
POST /passkey/discover/finish Handlers.DiscoverFinish

The begin handlers answer {"challenge": ...}; the finish handlers take webauthn.mjs's register() or authenticate() result. RegisterFinish also reads an optional "label", the name the person gives the new credential.

A successful step-up calls sessions.SignIn, rotating the session with a fresh AuthTime โ€” exactly what sessions.RequireFresh checks โ€” and a method that says how the authenticator checked the person: Method ("passkey") when it verified them with a PIN, fingerprint or face, MethodUnverified ("passkey-nouv") when it only found somebody present. A finished registration rotates the session the same way: the ceremony proved the new passkey as surely as an assertion would. The Gate flow suffixes the same two words onto the first factor.

Under a basepath, and on the app's direct host, every endpoint answers 404 and logs an error, because a tenant there signs people in through its hub.

Discover: the passkey as the front door

A passkey asserted with user verification has already proved possession and the person, so DiscoverBegin/DiscoverFinish let it sign in on its own: no session, no half-session, no address typed. The browser is asked for any credential it holds for this relying party (allowCredentials empty) and the credential it answers with says who. Config.Authorize, when set, is asked before anyone is admitted this way โ€” the roster check the app's other doors take.

An assertion WITHOUT user verification is a weaker proof. With Config.OtherFactor reporting another factor for the subject, the sign-in is held at the Gate as MethodUnverified for that factor to complete, and the JSON answer's "to" is the confirm page; with nothing else to prove, the person is signed in at the weaker method and it is the app's job to nudge. Verified or not, the answer carries "to", the same-site return_to or Config.SignedInPath.

Config.Remember takes auth's RememberJar(). With it, a verified discover assertion ends the sign-in screen's attempt and remembers passkey as this browser's way in, with no address.

The inventory

func (h *Handlers) List(subject string) ([]Info, error)
func (h *Handlers) Rename(subject, id, label string) error
func (h *Handlers) Remove(subject, id string) error

Info is one credential as a person's Security page shows it: ID (the credential id, hex), Label, UserVerified (the authenticator verified the user at registration, so a policy rates it a tier higher), BackupEligible and BackupState (a synced passkey, or one bound to the device it lives on), AAGUID (the authenticator's make, where attestation carried one), CreatedAt and LastUsedAt. Nothing secret is in it. Rename and Remove take the subject with the id, so a handle from one person's list is never redeemed by another; an id that is not theirs answers ErrNotYours. Whether an account may lose a credential โ€” its last factor, its only strong one โ€” is the app's rule to hold before calling Remove.

The timeouts

A challenge lives two minutes: long enough for an authenticator prompt, short enough that an abandoned one is not a standing invitation.

A pending half-session lives five minutes. Miss that window and you sign in again from the top.

Gate

func (h *Handlers) Gate(w http.ResponseWriter, r *http.Request, sess sessions.Session) (bool, error)

The SecondFactor hook both identity plugins expose. Called where a 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 โ€” and redirects to Config.ConfirmPath.

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 it on for everyone and let enrollment decide who it applies to.

Handlers.Enrolled(subject) reports whether an account has a credential, for a settings page or a conditional prompt.

Recovery codes

func (h *Handlers) RegenerateRecoveryCodes(subject string) ([]string, error)
func (h *Handlers) RecoveryCodesRemaining(subject string) (int, error)

RegenerateRecoveryCodes mints ten single-use codes and replaces any existing set. Show them once, from a page you mount behind sessions.RequireFresh.

secondfactor's Gate.SignInRecovery, at POST /signin/recovery, redeems one against the pending half-session where an assertion would have gone. It is a plain form POST reading the field code, with no JavaScript, deliberately: recovery is exactly the moment WebAuthn is not working.

A wrong code does not consume the half-session; it redirects to ConfirmPath?recovery=failed so another can be tried. 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 re-enrollment.

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, held for at most five minutes, and ten codes at 2โปโตโฐ apiece put brute force far below any practical odds inside that window.

Sweep

func Sweep(db *sql.DB, now time.Time) error

Deletes expired challenges and pending half-sessions. Both are refused on read once expired, so this is hygiene rather than enforcement.

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