Skip to content
theAuth

Add passkey sign in to a Go app with theAuth

A passkey is a key pair the user's device keeps, so there is no password to phish or reuse. This guide turns on WebAuthn in theAuth for Go, wires registration and discoverable sign in in the browser, and gives users control over their passkeys.

  • Practitioner
  • 3 min read
On this page

Before you start

  • A Go HTTP server that uses a chi router, or can mount one.
  • Users who can already sign in another way, because registering a passkey needs a signed in session.
  • HTTPS in production. Browsers only allow WebAuthn on an HTTPS origin or on localhost.

How the pieces fit

Each ceremony is two calls. The server hands out a single use challenge, the browser asks the authenticator to sign it, and the server verifies the result. Registration runs for a user who is already signed in and stores the new credential. Sign in is discoverable: the browser offers the passkeys it has for your site, so the user does not type a name first, and a verified assertion starts a session.

Turn it on in the server

  1. Step 1Add the module

    theAuth runs inside your process and keeps users, sessions and passkeys in your own database.

    bash
    go get github.com/glincker/theauth-go/v2
  2. Step 2Configure the relying party

    Set Config.WebAuthn. RPID is your registrable domain with no scheme and no port, and RPOrigins lists the exact origins the browser uses. Every built in store (memory, SQLite, Postgres and MySQL) can hold passkeys; use memory only to try it out.

    auth.go
    a, err := theauth.New(theauth.Config{
    	Storage: store, // SQLite, Postgres or MySQL for real data
    	BaseURL: "https://myapp.com",
    	WebAuthn: &theauth.WebAuthnConfig{
    		RPID:          "myapp.com",
    		RPDisplayName: "My App",
    		RPOrigins:     []string{"https://myapp.com"},
    	},
    })
    if err != nil {
    	log.Fatal(err)
    }
    defer a.Close()
  3. Step 3Mount the routes

    Mount adds the passkey routes under /auth/webauthn: register/begin and register/finish for a signed in user, login/begin and login/finish for anyone, each rate limited per client address.

    go
    r := chi.NewRouter()
    a.Mount(r)
    log.Fatal(http.ListenAndServe(":8080", r))

Wire it in the browser

  1. Step 4Add two base64url helpers

    The begin endpoints return challenges and ids as base64url text, and the browser API wants binary buffers, so convert both ways.

    passkeys.js
    // WebAuthn sends binary fields; the JSON endpoints use base64url.
    function b64uToBuf(s) {
      const pad = "=".repeat((4 - (s.length % 4)) % 4);
      const bin = atob((s + pad).replace(/-/g, "+").replace(/_/g, "/"));
      return Uint8Array.from(bin, (c) => c.charCodeAt(0)).buffer;
    }
    
    function bufToB64u(buf) {
      let s = "";
      for (const b of new Uint8Array(buf)) s += String.fromCharCode(b);
      return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
    }
  2. Step 5Register a passkey for the signed in user

    Call this from an account page. The browser shows its own prompt, and the name query parameter labels the passkey so the user can tell their devices apart later.

    js
    async function registerPasskey(name) {
      const opt = await (await fetch("/auth/webauthn/register/begin", { method: "POST" })).json();
      const pk = opt.publicKey;
      pk.challenge = b64uToBuf(pk.challenge);
      pk.user.id = b64uToBuf(pk.user.id);
      pk.excludeCredentials?.forEach((c) => (c.id = b64uToBuf(c.id)));
    
      const cred = await navigator.credentials.create({ publicKey: pk });
      const res = await fetch(`/auth/webauthn/register/finish?name=${encodeURIComponent(name)}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          id: cred.id,
          rawId: bufToB64u(cred.rawId),
          type: cred.type,
          response: {
            attestationObject: bufToB64u(cred.response.attestationObject),
            clientDataJSON: bufToB64u(cred.response.clientDataJSON),
          },
        }),
      });
      if (!res.ok) throw new Error(`register failed: ${res.status}`);
    }
  3. Step 6Sign in with a passkey

    No user name is needed. The browser lists the passkeys it holds for this site, and a verified assertion starts a session the same way a password sign in does.

    js
    async function signInWithPasskey() {
      const opt = await (await fetch("/auth/webauthn/login/begin", { method: "POST" })).json();
      const pk = opt.publicKey;
      pk.challenge = b64uToBuf(pk.challenge);
      pk.allowCredentials?.forEach((c) => (c.id = b64uToBuf(c.id)));
    
      const cred = await navigator.credentials.get({ publicKey: pk });
      const r = cred.response;
      const res = await fetch("/auth/webauthn/login/finish", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          id: cred.id,
          rawId: bufToB64u(cred.rawId),
          type: cred.type,
          response: {
            authenticatorData: bufToB64u(r.authenticatorData),
            clientDataJSON: bufToB64u(r.clientDataJSON),
            signature: bufToB64u(r.signature),
            userHandle: r.userHandle ? bufToB64u(r.userHandle) : null,
          },
        }),
      });
      if (!res.ok) throw new Error(`sign in failed: ${res.status}`);
    }

Manage and harden

  1. Step 7Let users list, rename and remove passkeys

    Three routes need a signed in session: list the user's passkeys, rename one, and delete one. Rename answers 501 when the storage backend does not support it.

    js
    const list = await (await fetch("/auth/webauthn/credentials")).json();
    
    await fetch(`/auth/webauthn/credentials/${id}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Work laptop" }),
    });
    
    await fetch(`/auth/webauthn/credentials/${id}`, { method: "DELETE" });
  2. Step 8Require a PIN or biometric

    By default user verification is preferred, not required. Set RequireUserVerification so a passkey always comes with a PIN or biometric check and is never a bare possession factor.

    go
    WebAuthn: &theauth.WebAuthnConfig{
    	RPID:                    "myapp.com",
    	RPDisplayName:           "My App",
    	RPOrigins:               []string{"https://myapp.com"},
    	RequireUserVerification: true,
    },
Questions

Frequently asked questions

What should the RPID be?

The registrable domain of your site, with no scheme and no port, for example myapp.com. For local development use localhost, with the matching localhost origin in RPOrigins.

Do passkeys work over plain HTTP?

Only on localhost. Browsers allow WebAuthn on HTTPS origins and on localhost, so put TLS in front of any other host, including a local test domain.

What happens if a passkey looks cloned?

theAuth checks that the authenticator's sign count goes up on every sign in. If it does not, the sign in is rejected by default. Authenticators that always report zero are allowed, as the WebAuthn spec requires.

Keep going
Next steps

Take it further with theAuth

The product page covers what it does, how to install it and where the docs live.