·2 min read·← All posts
Go WebAuthn Passkeys Security Stdlib

Why passkeys

Passkeys are phishing-resistant by construction. The relying party (your server) generates a challenge; the authenticator (the user’s hardware key or device TPM) signs it; the server verifies the signature against the registered public key.

There is no shared secret to steal. The signature is bound to the relying party’s origin — a phishing site can’t forge a valid signature because the origin won’t match.

The two ceremonies

Registration (once, when a user first sets up a passkey):

  1. Server generates challenge.
  2. Client passes challenge to the authenticator.
  3. Authenticator generates a key pair, returns the public key + attestation.
  4. Server stores the public key linked to the user.

Assertion (every subsequent login):

  1. Server generates challenge.
  2. Client passes challenge to the authenticator.
  3. Authenticator signs the challenge with the private key.
  4. Server verifies the signature against the stored public key.

Genie’s implementation

// pkg/auth/webauthn/webauthn.go
func (s *Service) FinishRegistration(
    ceremonyID, credentialID string,
    pubKey ed25519.PublicKey,
    clientChallenge []byte,
) error {
    ceremony := s.ceremonies[ceremonyID]
    if !equalBytes(clientChallenge, ceremony.Challenge) {
        return ErrChallengeMismatch
    }
    s.credentials[credentialID] = Credential{
        UserID:    ceremony.UserID,
        PublicKey: pubKey,
    }
    return nil
}

func (s *Service) FinishAssertion(
    ceremonyID, credentialID string,
    authenticatorData, clientDataJSON, signature []byte,
) (userID string, err error) {
    cred := s.credentials[credentialID]
    if cred.PublicKey == nil { return "", ErrUnknownCredential }

    // Per WebAuthn: signed message = authenticatorData || SHA-256(clientDataJSON)
    clientDataHash := sha256.Sum256(clientDataJSON)
    signedMessage := append(authenticatorData, clientDataHash[:]...)

    if !ed25519.Verify(cred.PublicKey, signedMessage, signature) {
        return "", ErrSignatureInvalid
    }
    return cred.UserID, nil
}

crypto/ed25519 is stdlib. The WebAuthn ceremony is explicit (challenge → signature → verify). No third-party WebAuthn library.

What this beats

Method Phishing-resistant? Replay-resistant?
Password No No (until cycle)
Password + TOTP No (TOTP can be phished) Yes (short window)
Password + Push Partially (push-fatigue attacks) Yes
Passkey (FIDO2) Yes Yes

For a bank or any high-stakes consumer app, passkeys + bcrypt-as-fallback is the right shape. TOTP is now a legacy upgrade path, not a target state.

What teams skip

Origin binding. The client data JSON includes the relying party’s origin. The server has to check that the origin matches. Skipping this check is what re-enables phishing.

User verification flag. The authenticator’s data byte includes a UV (user verification) flag — “did the user actually touch the key / use biometric.” For high-stakes operations, require UV=1.

Genie checks both. Together they make the passkey flow what the marketing says it is: phishing-resistant, password-less, in 200 lines of Go.

← Back to all posts