passkey

package module
v0.0.0-...-f918352 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 23, 2026 License: BSD-3-Clause Imports: 22 Imported by: 0

Documentation

Overview

Package passkey implements the server (Relying Party) side of WebAuthn for discoverable credentials (passkeys).

Although WebAuthn supports a range of purposes and policies, this package is designed for password-less authentication with discoverable credentials: i.e. logging into an account with a passkey.

The package is built around passkey records: opaque strings, handled like prefixed password hashes, that encode everything the server needs to store about a credential.

$webauthn$v=1$transports=hybrid+internal$<base64 authenticator data>

Records are immutable: they are produced by RelyingParty.Register and never change afterwards. Mutable authenticator state (like backup state) is not tracked; per-login values are reported by RelyingParty.Login as LoginResult.BackedUp and LoginResult.UserVerified.

Storage model

Applications are expected to store passkey records in a table keyed only by user ID, for example

CREATE TABLE passkeys (
    user_id TEXT NOT NULL,
    record  TEXT NOT NULL,
    FOREIGN KEY(user_id) REFERENCES users(passkeys_user_id)
);
CREATE INDEX passkeys_user_id ON passkeys(user_id);

with no index on credential IDs and no uniqueness constraints. No other columns are needed by this package; applications may add their own (e.g. a nickname, creation time, or last-used time for a passkey management UI). Lookups always resolve the user first (from the session during registration, from Response.UnauthenticatedUserID or the session during login) and then pass all of the user's records to RelyingParty.Login, which selects the right one.

(Because credentials are never resolved by credential ID across accounts, a credential ID registered maliciously into one account can never affect authentication for another, and the cross-account uniqueness check recommended by the WebAuthn specification becomes unnecessary.)

User IDs

User IDs are opaque strings, at most 64 bytes. They are stored inside the authenticator and returned in every login, so they MUST NOT contain personal information such as usernames or email addresses, and they cannot be changed later. Generate them with crypto/rand.Text at account creation and map them to accounts in the database. Do not reuse internal account identifiers.

For example

ALTER TABLE users ADD COLUMN passkeys_user_id TEXT;
CREATE UNIQUE INDEX users_passkeys_user_id ON users(passkeys_user_id);

Challenges

RelyingParty.NewLogin and RelyingParty.NewLoginWithOptions return an opaque request value that must be presented back to RelyingParty.Login.

The application must:

  • store the request, either server-side (in memory or in a KV store, keyed by RequestID) or client-side in a cookie;
  • if possible, delete it after use, so that each request is accepted at most once (client-side storage can't enforce this: an attacker who captured both request and response can replay the login until the request expires); and
  • protect its integrity if stored client-side (e.g. with an authenticated cookie), as a forged request could defeat challenge freshness.

Requests expire Options.Timeout after creation (which RequestCreation returns), and the application can use the same window as the lifetime of stored requests (e.g. as the KV TTL or cookie Max-Age).

Request values don't need to be kept secret but must be protected from tampering.

(Registration has no request value: with attestation "none", no part of a registration response is signed by a party the server trusts, so a registration challenge can't prove freshness and is not verified. Applications tie a registration to the signed-in session, and must protect the registration endpoint with their usual session and cross-site request forgery defenses, like any other authenticated endpoint.)

WebAuthn interface details

Registration options request a discoverable credential (residentKey: "required") with user verification "preferred", attestation "none", and the credProps extension.

The requested algorithms are, in order of preference, ML-DSA-44, ES256, and RS256.

The user name is also sent as the displayName, which credential providers ignore in practice.

Register fails if the response reports (via the credProps extension) that the created credential is not discoverable. Absence of the extension output is accepted: some clients (notably Safari) never report it, and enforcement of discoverability rests on the client's residentKey: "required" obligation.

Register does not require the user presence flag, so that conditional (automatic) passkey creation flows, in which the user does not interact with a prompt, are supported.

Register verifies that the client data is well-formed JSON with type "webauthn.create", the expected origin, and crossOrigin absent or false; that the authenticator data carries the hash of the RP ID; and, unless Options.OptionalUserVerification is set, that user verification is set up on the authenticator (see the User verification section below). The challenge is not verified; see the Challenges section above.

Login options request user verification "required", or "preferred" if Options.OptionalUserVerification is set. RelyingParty.NewLogin sends an empty allowCredentials list; LoginOptions.AllowCredentials populates it with the user's credentials instead.

Credential descriptors, in excludeCredentials at registration and in allowCredentials at login, carry the transports recorded at registration, which help the client reach the right authenticator.

Both registration and login options carry Options.Timeout as the ceremony timeout hint.

Login verifies that the signature is valid, with the matched record's public key, over the authenticator data and the hash of the client data; that the client data is well-formed JSON with type "webauthn.get", the request's challenge, the expected origin, and crossOrigin absent or false; that the authenticator data carries the hash of the RP ID (as does the matched record) and has the user presence flag set; that the request has not expired; and, unless Options.OptionalUserVerification is set, that user verification was performed and can be relied upon (see the User verification section below).

The signature counter is not checked (it is zero for the major passkey providers).

User verification

The UV flag of a login assertion is relied upon (and LoginResult.UserVerified is set) only if the matched record has the UV or the BE flag, i.e. if user verification was performed at registration or the credential is a synced passkey. Otherwise, it suggests the credential was stored on an external authenticator without UV set up, and an attacker that were to steal it could have set up their own PIN to enable UV. UserVerificationAvailable reports this property of a record.

By default, login options request user verification "required", and Login fails unless LoginResult.UserVerified would be true: with ErrUserVerificationUnavailable if the record has neither the UV nor the BE flag (likely a security key without a PIN), and with a generic error otherwise. Registration options request "preferred", to allow conditional (automatic) passkey creation, but Register fails with ErrUserVerificationUnavailable if the response has neither the UV nor the BE flag, as the credential could never log in.

If Options.OptionalUserVerification is set, login options request "preferred", Register and Login accept responses regardless of the flags, and LoginResult.UserVerified retains the same semantics.

Example
package main

import (
	"crypto/rand"
	"errors"
	"io"
	"log"
	"net/http"
	"sync"
	"time"

	"filippo.io/passkey"
)

func main() {
	const page = `<!DOCTYPE html>
<meta charset="utf-8">
<title>passkey example</title>
<button id="login">Sign in</button>
<hr>
<input id="username" placeholder="username" autocomplete="username">
<button id="register">Sign up with a passkey</button>
<hr>
<p id="out"></p>
<script type="module">
addEventListener("unhandledrejection", e => out.textContent = e.reason)
const post = async (url, body) => {
const res = await fetch(url, { method: "POST", body })
if (!res.ok) throw new Error(await res.text())
return res
}
register.onclick = async () => {
const options = await (await post("/register", username.value)).json()
const credential = await navigator.credentials.create({
	publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
})
await post("/add-passkey", JSON.stringify(credential))
out.textContent = "Account created."
}
const loginOptions = () => post("/login/options").then(res => res.json())
let nextLogin = loginOptions()
login.onclick = async () => {
const options = await nextLogin
nextLogin = loginOptions() // requests are single-use
const credential = await navigator.credentials.get({
	publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
})
const res = await post("/login", JSON.stringify(credential))
out.textContent = "Signed in as " + await res.text() + "."
}
</script>
`

	rp, err := passkey.NewRelyingParty(&passkey.Options{
		// The RP ID is the site's registrable domain, so passkeys keep
		// working if sign-in later moves to a different origin.
		RPID:   "example.com",
		Origin: "https://login.example.com",
	})
	if err != nil {
		log.Fatal(err)
	}

	// Stand-ins for the application's session and database.
	type user struct {
		username      string
		passkeyUserID string
		passkeys      []string
	}
	var sessionUser func(*http.Request) (*user, error)
	var sessionSignIn func(rw http.ResponseWriter, username string)
	var userByUserID func(passkeyUserID string) (*user, error)
	var registerNewUser func(username, passkeyUserID string) (*user, error)
	var requests sync.Map // request ID -> pending login request

	mux := http.NewServeMux()

	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		io.WriteString(w, page)
	})

	mux.HandleFunc("POST /register", func(w http.ResponseWriter, r *http.Request) {
		username, _ := io.ReadAll(r.Body)
		// A user ID must be opaque and unique, so we generate a random one.
		u, err := registerNewUser(string(username), rand.Text())
		if err != nil {
			http.Error(w, "user already exists", http.StatusConflict)
			return
		}
		sessionSignIn(w, u.username)
		optionsJSON, err := rp.NewRegistration(
			passkey.User{ID: u.passkeyUserID, Name: u.username}, nil)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		w.Write(optionsJSON)
	})

	mux.HandleFunc("POST /add-passkey", func(w http.ResponseWriter, r *http.Request) {
		u, err := sessionUser(r)
		if err != nil {
			http.Error(w, "not signed in", http.StatusUnauthorized)
			return
		}
		responseJSON, _ := io.ReadAll(r.Body)
		record, err := rp.Register(responseJSON)
		if err != nil {
			switch {
			case errors.Is(err, passkey.ErrUserVerificationUnavailable):
				http.Error(w, "this passkey can't verify it's you: set up a PIN "+
					"on your security key, or use a different authenticator",
					http.StatusBadRequest)
			default:
				http.Error(w, "registration failed", http.StatusBadRequest)
			}
			return
		}
		u.passkeys = append(u.passkeys, record)
	})

	mux.HandleFunc("POST /login/options", func(w http.ResponseWriter, r *http.Request) {
		request, optionsJSON, err := rp.NewLogin()
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		requests.Store(passkey.RequestID(request), request)
		w.Write(optionsJSON)
	})

	mux.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
		responseJSON, _ := io.ReadAll(r.Body)
		response, err := passkey.ParseResponse(responseJSON)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		// Each request is deleted after use, so it can't be replayed.
		request, ok := requests.LoadAndDelete(response.RequestID())
		// The asserted user ID is attacker-controlled until Login succeeds,
		// and is only used to look up the candidate passkey records.
		u, err := userByUserID(response.UnauthenticatedUserID())
		if !ok || err != nil {
			http.Error(w, "login failed", http.StatusUnauthorized)
			return
		}
		if _, err := rp.Login(response, request.([]byte), u.passkeys); err != nil {
			switch {
			case errors.Is(err, passkey.ErrUnknownCredential):
				http.Error(w, "this passkey was removed from the account",
					http.StatusUnauthorized)
			case errors.Is(err, passkey.ErrRequestExpired):
				http.Error(w, "took too long, please try again", http.StatusUnauthorized)
			default:
				http.Error(w, "login failed", http.StatusUnauthorized)
			}
			return
		}
		sessionSignIn(w, u.username)
		io.WriteString(w, u.username)
	})

	// Evict expired pending requests to reclaim memory;
	// a production KV store would rely on a TTL instead.
	go func() {
		for range time.Tick(time.Minute) {
			requests.Range(func(key, value any) bool {
				created := passkey.RequestCreation(value.([]byte))
				if time.Since(created) > 5*time.Minute {
					requests.Delete(key)
				}
				return true
			})
		}
	}()

	// CSRF protection is critical to avoid POST /add-passkey
	// being abused to add a passkey to another user's account.
	handler := http.NewCrossOriginProtection().Handler(mux)

	log.Fatal(http.ListenAndServe("localhost:8080", handler))
}

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrRequestExpired = errors.New("passkey: login request expired")

ErrRequestExpired is returned by RelyingParty.Login when the response is valid but the request was created more than Options.Timeout ago. Applications should discard the request and retry the ceremony with a fresh one.

It is always wrapped, so callers must use errors.Is.

View Source
var ErrUnknownCredential = errors.New("passkey: credential not registered for this user")

ErrUnknownCredential is returned by RelyingParty.Login when the response asserts a credential that is not among the provided passkey records. It can be surfaced to the user as "this passkey was removed from the account".

It is always wrapped, so callers must use errors.Is.

View Source
var ErrUnsupportedAlgorithm = errors.New("unsupported credential public key algorithm")

ErrUnsupportedAlgorithm is returned by RelyingParty.Register and by AAGUID when a passkey record has a credential public key type that this package does not support.

It is always wrapped, so callers must use errors.Is.

View Source
var ErrUserVerificationUnavailable = errors.New("passkey: user verification unavailable")

ErrUserVerificationUnavailable is returned by RelyingParty.Register and RelyingParty.Login when the response is valid, but the passkey can't provide user verification (e.g. PIN or biometrics) that can be relied upon. It is never returned if Options.OptionalUserVerification is set.

If returned by Register, the authenticator is likely a security key without a PIN. It can be surfaced to the user as "this passkey can't verify it's you", suggesting they configure a PIN on their security key or use a different passkey authenticator.

If returned by Login, the matched record was generated with Options.OptionalUserVerification or imported. It requires replacing the passkey with a new registration, potentially after the user has configured a PIN on their security key.

It is always wrapped, so callers must use errors.Is.

Functions

func AAGUID

func AAGUID(passkey string) ([16]byte, error)

AAGUID returns the authenticator's AAGUID from a passkey record, which may identify the passkey provider (e.g. for display purposes, using the community-maintained AAGUID lists). It is all zeroes if the authenticator did not provide one.

func RequestCreation

func RequestCreation(request []byte) time.Time

RequestCreation returns the creation time of a request returned by RelyingParty.NewLogin or RelyingParty.NewLoginWithOptions.

A request expires Options.Timeout after creation.

If the request is invalid, RequestCreation returns the zero time.Time.

func RequestID

func RequestID(request []byte) string

RequestID returns a unique identifier for a request returned by RelyingParty.NewLogin or RelyingParty.NewLoginWithOptions, to be used as a storage key. The same value is returned by Response.RequestID for the corresponding response.

If the request is invalid, RequestID returns an empty string.

func UserVerificationAvailable

func UserVerificationAvailable(passkey string) (bool, error)

UserVerificationAvailable reports whether the passkey can provide user verification (e.g. PIN or biometrics) that can be relied upon.

If false, RelyingParty.Login with this record returns ErrUserVerificationUnavailable unless Options.OptionalUserVerification is set.

Types

type LoginOptions

type LoginOptions struct {
	// AllowCredentials is a list of passkey records for a user that the application
	// has already identified. It can be used e.g. for a re-authentication
	// prompt before a sensitive operation in a signed-in session.
	//
	// The user's passkey records are communicated to the client so it
	// offers only that user's credentials instead of an account picker.
	// The user is identified by the application, not by the response:
	// [Response.UnauthenticatedUserID] is empty when AllowCredentials is set,
	// and the application must look up the user's records the same way
	// it did to populate this field (e.g. from the session) and pass
	// them to [RelyingParty.Login].
	//
	// The login options disclose the user's credential IDs to whoever receives it,
	// so this field should be nil for a fully unauthenticated username.
	AllowCredentials []string
}

LoginOptions configures the behavior of RelyingParty.NewLoginWithOptions.

The zero value is a valid configuration, and is equivalent to calling RelyingParty.NewLogin.

type LoginResult

type LoginResult struct {
	// Matched is the index in passkeys of the record the response
	// was verified against.
	Matched int

	// UserVerified reports whether user verification (e.g. PIN or
	// biometrics) was performed and can be relied upon.
	UserVerified bool

	// BackedUp reports whether the credential asserts it is currently
	// backed up (e.g. synced to a cloud account). It can inform prompting
	// the user to remove their password from the account, if any.
	BackedUp bool
}

LoginResult is returned by RelyingParty.Login on a successful login.

Unlike the Response methods, which return attacker-controlled lookup keys, LoginResult fields are authenticated.

type Options

type Options struct {
	// RPID is the Relying Party ID. The RP ID scopes which credentials exist
	// for an origin, it is stored in every credential, and it can never change.
	//
	// Generally, the registrable domain of the site (e.g. example.com) is a
	// good RP ID.
	RPID string

	// Origin is the origin from which registrations and logins are expected and
	// allowed. It can change over time or across endpoints.
	//
	// It must match exactly the origin of the page that calls
	// navigator.credentials.create() or navigator.credentials.get(), or the
	// platform-specific equivalent.
	//
	// Examples of valid origins are "https://example.com" and
	// "https://accounts.example.com:8443" and "android:apk-key-hash:...".
	//
	// Origin may be outside the RP ID's domain, if authorized through Related
	// Origin Requests (the /.well-known/webauthn document, which the
	// application is responsible for serving).
	//
	// To accept ceremonies from multiple origins, create a RelyingParty per
	// origin, and select it based on the endpoint handling the request.
	Origin string

	// OptionalUserVerification disables the user verification (e.g. PIN or
	// biometrics) requirement. This is mostly appropriate if a passkey is a
	// second factor used alongside e.g. a password.
	//
	// By default, login ceremonies require user verification and Login fails
	// unless it was performed and can be relied upon.
	// If OptionalUserVerification is true, login ceremonies request user
	// verification but don't require it. Applications can consult
	// [LoginResult.UserVerified], which retains the same semantics.
	//
	// Registration ceremonies never require user verification, but by default
	// Register rejects credentials that are not capable of it.
	// If OptionalUserVerification is true, Register will accept some rare
	// credentials that will only work for logins with OptionalUserVerification.
	OptionalUserVerification bool

	// Timeout is how long a request returned by NewLogin or
	// NewLoginWithOptions remains valid.
	//
	// If zero, it defaults to five minutes. Conditional UI (autofill)
	// logins may warrant a longer timeout, as the prompt can sit idle
	// for a while before the user engages with it.
	Timeout time.Duration
}

Options configures a RelyingParty.

type RelyingParty

type RelyingParty struct {
	// contains filtered or unexported fields
}

A RelyingParty verifies registrations and logins. It holds no state about registered passkeys or ongoing login ceremonies.

Its methods are safe for concurrent use by multiple goroutines.

func NewRelyingParty

func NewRelyingParty(opts *Options) (*RelyingParty, error)

NewRelyingParty returns a RelyingParty configured with the given options.

Options.RPID and Options.Origin must be set.

func (*RelyingParty) Login

func (rp *RelyingParty) Login(response *Response, request []byte, passkeys []string) (*LoginResult, error)

Login verifies a login response against the request returned by RelyingParty.NewLogin and the given user's passkey records.

On success, it returns a LoginResult reporting the index in passkeys of the record the response was verified against.

Failures that call for special handling return sentinel errors and are documented below. All other errors should be logged but not exposed to the user.

  • If the response is valid but matches none of passkeys, Login returns ErrUnknownCredential: the user asserted a credential the server no longer (or never) had a record of, for example one deleted from account settings but still present in the user's passkey provider.

  • If the response is valid but the matched record suggests user verification can't be relied upon, Login returns ErrUserVerificationUnavailable unless Options.OptionalUserVerification is set.

  • If the response is valid but the request is older than Options.Timeout, Login returns ErrRequestExpired: the application can transparently retry with a fresh request, which is routine for conditional UI (autofill) prompts left idle.

func (*RelyingParty) NewLogin

func (rp *RelyingParty) NewLogin() (request, optionsJSON []byte, err error)

NewLogin begins a login ceremony.

optionsJSON is a PublicKeyCredentialRequestOptions object to be

  1. parsed from JSON on the client side, then
  2. passed to PublicKeyCredential.parseRequestOptionsFromJSON(), and then
  3. passed to navigator.credentials.get() as the publicKey field.

It can be used both for modal and for conditional UI (autofill) flows.

request is an opaque value to be stored by the application (see [Challenges], and RequestID for a storage key) and passed to Login.

Note that a login ceremony is not started for a specific user: the user is identified by the response, using Response.UnauthenticatedUserID.

[Challenges]: #hdr-Challenges

func (*RelyingParty) NewLoginWithOptions

func (rp *RelyingParty) NewLoginWithOptions(options *LoginOptions) (request, optionsJSON []byte, err error)

NewLoginWithOptions begins a login ceremony with the given options.

If options is nil or the zero value, it is equivalent to calling RelyingParty.NewLogin.

func (*RelyingParty) NewRegistration

func (rp *RelyingParty) NewRegistration(user User, passkeys []string) (optionsJSON []byte, err error)

NewRegistration begins the registration of a new passkey for user.

passkeys is the user's full list of existing passkey records, or nil if the user has none. They are communicated to the client as excludeCredentials, so an authenticator that already holds one of them for this RP will refuse to create a new credential, and the user won't end up with duplicate passkeys on the same authenticator.

optionsJSON is a PublicKeyCredentialCreationOptions object to be

  1. parsed from JSON on the client side, then
  2. passed to PublicKeyCredential.parseCreationOptionsFromJSON(), and then
  3. passed to navigator.credentials.create() as the publicKey field.

It can be used both for modal and for conditional UI flows.

Registration is stateless for the server: there is no request value to store, and the response is verified by RelyingParty.Register.

func (*RelyingParty) Register

func (rp *RelyingParty) Register(responseJSON []byte) (passkey string, err error)

Register verifies a registration response and, on success, returns the new passkey record, to be stored for the signed-in user whose session initiated the registration.

responseJSON is the JSON serialization of the PublicKeyCredential returned by navigator.credentials.create() (as produced by its toJSON() method).

type Response

type Response struct {
	// contains filtered or unexported fields
}

Response is a parsed PublicKeyCredential returned by navigator.credentials.get().

It is NOT verified until it is passed to RelyingParty.Login and Login succeeds. It is meant to be used for looking up the request and the user's passkey records; authenticated values are reported by Login in LoginResult.

func ParseResponse

func ParseResponse(responseJSON []byte) (*Response, error)

ParseResponse parses a login response into a Response.

responseJSON is the JSON serialization of the PublicKeyCredential returned by navigator.credentials.get() (as produced by its toJSON() method).

It does not verify the response: use RelyingParty.Login to verify it against the request and the user's passkey records.

func (*Response) RequestID

func (r *Response) RequestID() string

RequestID returns the unique identifier of the request this response is for, as returned by RequestID when the request was created. It can be used to look up the stored request.

func (*Response) UnauthenticatedUserID

func (r *Response) UnauthenticatedUserID() string

UnauthenticatedUserID returns the user ID asserted by this response.

This user ID is attacker-controlled, and must not be used for anything but looking up the user's passkey records.

It is empty if and only if the ceremony was initiated with LoginOptions.AllowCredentials, in which case the application already identified the user before, and must look up their records the same way, not from the response.

type User

type User struct {
	// ID is the opaque user ID; see [User IDs].
	//
	// [User IDs]: #hdr-User_IDs
	ID string

	// Name is a human-readable identifier for the account, such as a
	// username or email address. It must not be empty. It is displayed in
	// credential pickers and stored by the authenticator, but never
	// returned to the server or used in the protocol.
	Name string
}

User identifies the account a passkey is being registered for.

Directories

Path Synopsis
internal
ctap2cbor
Package ctap2cbor implements a tiny subset of CTAP2's subset of CBOR, sufficient to parse COSE keys within authenticator data, and to find the authenticator data within an attestation object.
Package ctap2cbor implements a tiny subset of CTAP2's subset of CBOR, sufficient to parse COSE keys within authenticator data, and to find the authenticator data within an attestation object.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL