Skip to content

Security

Passkeys (WebAuthn)

Register passkeys and sign users in with them using the stdlib-only auth/webauthn package - options for the browser, attestation "none", ES256, EdDSA and RS256 keys, origin and RP ID checks, user verification and sign-count clone detection.

Last updated:

A passkey is a key pair kept by the user's device or password manager. The private key never leaves it; your app stores only the public key. To sign in, the device signs a random challenge after the user unlocks it with a fingerprint, a face scan or the device PIN. There is no shared secret to phish, leak or reuse, and the browser only offers a passkey to the site it was created for.

The auth/webauthn package verifies the two WebAuthn ceremonies, registration and authentication, with the standard library only. It does not touch your database or session: you store the challenge and the credentials, and it tells you whether a ceremony is valid.

You will learn:

  • how to configure the relying party (your site);
  • how to register a passkey for a signed-in user;
  • how to sign a user in with a passkey, with or without a username;
  • how install:auth --passkeys wires it all into your app;
  • what the package checks, and what stays your job.

Configure the relying party#

The relying party is your site. ID is the domain credentials are bound to (example.com covers app.example.com too), and Origins are the exact origins a ceremony may come from. Validate it at boot:

app/providers/passkeys.go
rp := webauthn.RelyingParty{
	ID:      "example.com",
	Name:    "Example",
	Origins: []string{"https://example.com"},
}
if err := rp.Validate(); err != nil {
	return err
}

Validate refuses an origin outside the RP ID and plain http except on localhost. User verification (fingerprint, face or PIN on the device) is required by default, so a passkey alone is enough to sign in. InsecureSkipUserVerification turns that off; use it only when the passkey is a second factor next to a password.

Register a passkey#

Registration needs a signed-in user. Give every user a random, stable user handle once (webauthn.NewUserHandle()), never their id or email: the handle is stored on the device.

  1. Create a challenge, keep it in the session and send the options:
app/http/passkeys.go
challenge := webauthn.NewChallenge()
c.Session().Put("passkey.register", challenge)

user := webauthn.User{Handle: account.PasskeyHandle, Name: account.Email, DisplayName: account.Name}
return c.JSON(http.StatusOK, rp.RegistrationOptions(user, challenge, existing))

existing holds the user's credentials, so the browser does not register the same device twice.

  1. In the browser, pass the options through PublicKeyCredential.parseCreationOptionsFromJSON, call navigator.credentials.create({ publicKey }) and post credential.toJSON() back.

  2. Take the challenge out of the session (it must work once), verify and store:

app/http/passkeys.go
challenge, _ := c.Session().Pull("passkey.register").(string)

cred, err := rp.VerifyRegistration(challenge, user, body)
if err != nil {
	return errs.New(http.StatusUnprocessableEntity, "passkey not registered")
}

Save every webauthn.Credential field. ID must be unique across users.

Sign in with a passkey#

  1. Create a challenge, keep it in the session under passkey.login and send rp.AssertionOptions(challenge, nil). With no allow list the browser offers every passkey of your site, so the user signs in without typing a username.
  2. In the browser, use parseRequestOptionsFromJSON, call navigator.credentials.get({ publicKey }) and post credential.toJSON().
  3. Parse the response, load the credential by its id, verify, then save the new sign count before you log the user in:
app/http/passkeys.go
challenge, _ := c.Session().Pull("passkey.login").(string)

a, err := webauthn.ParseAssertion(body)
if err != nil {
	return errs.New(http.StatusUnauthorized, "passkey not accepted")
}

stored, err := credentials.FindByID(ctx, a.CredentialID)
if err != nil {
	return errs.New(http.StatusUnauthorized, "passkey not accepted")
}

updated, err := rp.VerifyAssertion(challenge, stored, a)
if err != nil {
	return errs.New(http.StatusUnauthorized, "passkey not accepted")
}

Answer every failure with the same message; the error tells your logs why.

Use the scaffold#

vukem install:auth --passkeys writes all of the above into your app as code you own: the table and migration, the JSON endpoints, the throttles and the tests. Run it on a project with install:auth (or together with it), then vukem migrate.

RouteWhat it does
GET /user/passkeysLists the signed-in user's passkeys (name, backed up, dates)
POST /user/passkeys/optionsChecks password, then returns the creation options
POST /user/passkeysStores the passkey: {"name": "...", "credential": credential.toJSON()}
POST /user/passkeys/{id}/deleteChecks password, then removes one of the user's passkeys
POST /passkeys/login/optionsReturns the request options for a usernameless sign-in
POST /passkeys/loginVerifies the assertion, signs in, returns {"redirect": "/dashboard"}

The relying party comes from APP_URL: its host is the RP ID and its origin the only allowed origin. The password stays on as the recovery path, and adding or removing a passkey asks for it, so a stolen session cannot add a way in.

Call the endpoints from your page:

TypeScript
// Add a passkey (signed in; the password is confirmed first).
const opts = await post("/user/passkeys/options", { password });
const created = await navigator.credentials.create({
  publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(opts),
});
await post("/user/passkeys", { name: "Laptop", credential: created.toJSON() });

// Sign in (usernameless).
const req = await post("/passkeys/login/options", {});
const got = await navigator.credentials.get({
  publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(req),
});
const { redirect } = await post("/passkeys/login", got.toJSON());
location.assign(redirect);

post is your fetch wrapper that sends JSON with the CSRF token header and returns the parsed body.

The generated tests run real ceremonies with auth/webauthn/webauthntest, a software passkey:

tests/passkeys_test.go
a := webauthntest.New("http://localhost:3000")
body, err := a.Create([]byte(optionsJSON)) // what credential.toJSON() would post

What the package checks#

CheckError
The challenge is the one you issued; an empty one (lost session) never matchesErrChallenge
The origin is listed, and the ceremony is not inside a cross-origin iframeErrOrigin
The authenticator signed for your RP IDErrRPID
The user was present, and verified unless you opted outErrUserPresence, ErrUserVerification
Attestation is none, the key is ES256 (P-256), Ed25519 or RS256 of at least 2048 bitsErrUnsupportedAttestation, ErrUnsupportedKey
The assertion uses this credential, and its user handle is the credential's userErrCredential
The signature over the authenticator data and client data verifiesErrSignature
The sign count went up (a count that stays at 0 is a synced passkey and is fine)ErrSignCount

Your app owns the rest:

  • Single-use challenges. Take the challenge out of the session before verifying, so a captured response cannot be replayed.
  • A recovery path. A user who loses every device needs another way in (password, recovery codes or a support flow). Never let a passkey become the only factor without one.
  • Throttling. Rate-limit the sign-in endpoint like the password login.
  • Sign-count failures. ErrSignCount can mean a cloned authenticator: refuse the sign-in and tell the user.

The package does not verify attestation statements (which device model made the key). Consumer passkeys do not provide them, and the browser reports none when the options ask for it, as RegistrationOptions does.