A comprehensive guide to understanding and implementing FIDO Passkey WebAuthn authentication.
🚀 Quick Start Guide - Get the demo running in 5 minutes!
- Quick Start
- What is FIDO WebAuthn?
- Core Concepts
- Registration Flow
- Authentication Flow
- Key Security Features
- System Architecture
- Data Flow Overview
- Implementation Guide
- API Reference
- Configuration
- Browser Support
- Troubleshooting
- Security Considerations
- Resources
- License
# Install dependencies
go mod download
# Run the server
go run main.go
# Open browser
open http://localhost:8080For detailed instructions, see QUICKSTART.md.
FIDO (Fast IDentity Online) is an authentication standard that enables passwordless authentication. WebAuthn is the W3C web standard that implements FIDO2 in web browsers, allowing websites to use public key cryptography for user authentication instead of passwords.
- Modern replacement for passwords: Cryptographic credentials that are more secure and user-friendly
- Cryptographic key pairs: Private key stored securely on device, public key stored on server
- Phishing-resistant: Keys are bound to specific domains
- Synced across devices: Via platform providers (Apple iCloud Keychain, Google Password Manager, etc.)
- Multi-device support: Use passkeys across your phones, tablets, and computers
Your web application/server that:
- Initiates registration and authentication ceremonies
- Stores public keys and credential IDs
- Verifies authentication responses
- Manages user sessions
User's device (phone, laptop, security key) that:
- Generates and stores private keys in secure hardware
- Signs challenges during authentication
- Performs user verification (biometrics, PIN)
- Can be platform authenticators (built-in Touch ID, Face ID, Windows Hello) or roaming authenticators (USB security keys like YubiKey)
Web browser that:
- Mediates between RP and authenticator
- Implements WebAuthn JavaScript API (
navigator.credentials) - Handles user consent and UI
The registration ceremony creates a new credential for a user.
sequenceDiagram
participant User
participant Browser
participant Server
participant Authenticator
User->>Browser: Click "Register"
Browser->>Server: Request registration options
Server->>Server: Generate random challenge
Server->>Server: Choose attestation level<br/>(none/indirect/direct/enterprise)
Server->>Browser: Return challenge + RP info + user info + attestation level
Browser->>Authenticator: navigator.credentials.create()
Authenticator->>User: Request biometric/PIN
User->>Authenticator: Provide verification
Authenticator->>Authenticator: Generate key pair
Authenticator->>Authenticator: Store private key securely
Authenticator->>Browser: Return credential response<br/>(public key + credential ID + attestation data)
Browser->>Server: Send credential response
alt attestation: "none" (This Demo)
Note over Server: Skip attestation verification<br/>Accept any authenticator
Server->>Server: Store public key + credential ID
else attestation: "indirect"
Server->>Server: Verify anonymized attestation
Server->>Server: Confirm authenticator is legitimate
Server->>Server: Store public key + credential ID
else attestation: "direct"
Server->>Server: Verify full attestation statement
Server->>Server: Check authenticator against policy<br/>(allowlist/blocklist)
Server->>Server: Store public key + credential ID + device info
else attestation: "enterprise"
Server->>Server: Verify enterprise attestation
Server->>Server: Validate device against MDM policy
Server->>Server: Store public key + credential ID + unique device ID
end
Server->>Browser: Registration success
Browser->>User: Show success message
- Server generates challenge: Creates random bytes (typically 32 bytes) to prevent replay attacks
- Client calls
navigator.credentials.create(): Passes challenge, RP info, and user info - Authenticator creates key pair: Private key stored in secure hardware (TPM, Secure Enclave), never leaves device
- Client returns credential: Contains public key, credential ID, and attestation data (if requested)
- Server verifies and stores:
- Verifies challenge matches the one sent
- Verifies origin matches expected RP origin
- Verifies RP ID matches
- Checks user verification flag (if required)
- Verifies attestation (if not "none")
- Stores public key and credential ID associated with user
const publicKeyCredentialCreationOptions = {
challenge: Uint8Array.from(randomStringFromServer, c => c.charCodeAt(0)),
rp: {
name: "Example Corp",
id: "example.com"
},
user: {
id: Uint8Array.from("UZSL85T9AFC", c => c.charCodeAt(0)),
name: "user@example.com",
displayName: "John Doe"
},
pubKeyCredParams: [{alg: -7, type: "public-key"}],
authenticatorSelection: {
authenticatorAttachment: "platform",
userVerification: "required"
},
timeout: 60000,
attestation: "none"
};
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
});The authentication ceremony verifies a user with an existing credential.
sequenceDiagram
participant User
participant Browser
participant Server
participant Authenticator
User->>Browser: Click "Sign In"
Browser->>Server: Request authentication options
Server->>Server: Generate random challenge
Server->>Browser: Return challenge + allowed credentials
Browser->>Authenticator: navigator.credentials.get()
Authenticator->>User: Request biometric/PIN
User->>Authenticator: Provide verification
Authenticator->>Authenticator: Retrieve private key
Authenticator->>Authenticator: Sign challenge
Authenticator->>Browser: Return assertion (signed challenge + credential ID)
Browser->>Server: Send authentication response
Server->>Server: Lookup public key by credential ID
Server->>Server: Verify signature with public key
Server->>Server: Create session
Server->>Browser: Authentication success + session token
Browser->>User: Redirect to dashboard
- Server generates challenge: New random challenge for this authentication attempt
- Client calls
navigator.credentials.get(): Passes challenge and list of allowed credential IDs - Authenticator signs challenge: Uses stored private key to create digital signature
- Client returns assertion: Contains signed challenge, credential ID, and authenticator data
- Server verifies signature:
- Looks up public key by credential ID
- Verifies challenge matches
- Verifies origin and RP ID
- Verifies signature using stored public key
- Checks signature counter to detect cloned authenticators
- Creates authenticated session if valid
const publicKeyCredentialRequestOptions = {
challenge: Uint8Array.from(randomStringFromServer, c => c.charCodeAt(0)),
allowCredentials: [{
id: Uint8Array.from(credentialId, c => c.charCodeAt(0)),
type: 'public-key',
transports: ['usb', 'ble', 'nfc', 'internal']
}],
timeout: 60000,
userVerification: "required"
};
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
});- Origin binding: Credentials are cryptographically bound to the domain
- No credential reuse: Each site gets unique credentials
- Browser enforced: Cannot be bypassed by malicious scripts
- Private keys never leave device: Stored in secure hardware (TPM, Secure Enclave)
- Public key cryptography: Server only stores public keys
- No password databases to breach: Even if server is compromised, attackers can't impersonate users
- Built-in 2FA: Combines possession (device) + verification (biometric/PIN)
- Biometric authentication: Touch ID, Face ID, Windows Hello
- PIN fallback: When biometrics unavailable
🔒 This demo uses attestation level:
"none"for maximum user privacy
Attestation allows the Relying Party (server) to verify the authenticity and type of the authenticator being used during registration. Different attestation levels provide different trade-offs between privacy and security.
| Attestation Level | Privacy | Security | When to Use | What Information is Provided |
|---|---|---|---|---|
none |
✅ Highest | Consumer apps, public services (like this demo) | No attestation data. Server cannot verify authenticator type or manufacturer. Maximum user privacy. | |
indirect |
✅ High | ✅ Medium | Privacy-conscious enterprise | Anonymized attestation. Server can verify authenticator is legitimate but cannot track individual devices. |
direct |
✅ Highest | High-security enterprise, regulated industries | Full attestation statement with manufacturer info. Server can verify exact authenticator model and apply device policies. | |
enterprise |
❌ Lowest | ✅ Maximum | Corporate environments with MDM | Full attestation + uniquely identifying information. Allows tracking specific devices for compliance. |
none (Used in this demo)
- ✅ Privacy: No tracking of device types or manufacturers
- ✅ Compatibility: Works with all FIDO2-compliant authenticators
- ✅ User experience: No additional prompts or delays
⚠️ Security: Cannot enforce device policies or block specific authenticators- 📱 Best for: Consumer applications, public websites, privacy-first services
indirect
- ✅ Privacy: Attestation is anonymized (e.g., via Anonymization CA)
- ✅ Security: Can verify authenticator is legitimate FIDO2 device
⚠️ Tracking: Cannot track individual devices, only verify authenticity- 📱 Best for: Enterprise apps that need some verification but respect privacy
direct
⚠️ Privacy: Reveals authenticator make, model, and batch- ✅ Security: Can enforce allowlist/blocklist of specific authenticator models
- ✅ Compliance: Meets regulatory requirements for device verification
- 📱 Best for: Banking, healthcare, government services
enterprise
- ❌ Privacy: Can uniquely identify specific devices
- ✅ Security: Full device attestation with unique identifiers
- ✅ Management: Integrates with Mobile Device Management (MDM)
- 📱 Best for: Corporate internal systems with managed devices
MDM stands for Mobile Device Management - security software used by IT departments to monitor, manage, and secure employees' devices (smartphones, tablets, laptops) deployed across the organization.
Key MDM Functions:
- Device Enrollment: Automatically configure devices with company policies, apps, and certificates
- Security & Compliance: Enforce password requirements, encryption, remote wipe capabilities
- Device Tracking: Maintain inventory and uniquely identify specific devices
- Policy Enforcement: Control device features, restrict apps, enforce updates
MDM in WebAuthn enterprise Attestation:
When using attestation: "enterprise", the authenticator provides unique device identifiers that the server can verify against the company's MDM system:
- ✅ Only allow passkeys from company-managed devices
- ✅ Block registration from personal/unmanaged devices
- ✅ Track which specific device registered each credential
- ✅ Revoke access if device is removed from MDM
Popular MDM Solutions:
- Microsoft Intune (Windows, iOS, Android)
- Jamf (Apple devices)
- VMware Workspace ONE
- Google Workspace
- IBM MaaS360
Privacy Trade-off: MDM + enterprise attestation provides maximum security and control but minimum privacy - every device can be uniquely tracked. This is why consumer apps (like this demo) use attestation: "none" instead.
- Challenge-response: Each authentication uses unique random challenge
- Signature counter: Detects cloned authenticators
- Time-bound: Challenges expire after timeout
graph TB
subgraph "Client Side"
A[Web Browser]
B[WebAuthn API]
C[Platform Authenticator<br/>Touch ID, Face ID, Windows Hello]
D[Roaming Authenticator<br/>USB Security Key]
end
subgraph "Server Side"
E[Web Server]
F[WebAuthn Library]
G[Database]
end
A --> B
B --> C
B --> D
A <--> E
E --> F
F --> G
G -.->|Stores| H[User Info<br/>Public Keys<br/>Credential IDs<br/>Sign Count]
flowchart LR
A[User Action] --> B{Registration or<br/>Authentication?}
B -->|Registration| C[Create Credential]
B -->|Authentication| D[Get Credential]
C --> E[Generate Key Pair]
E --> F[Store Private Key<br/>in Device]
F --> G[Send Public Key<br/>to Server]
G --> H[Server Stores<br/>Public Key]
D --> I[Sign Challenge<br/>with Private Key]
I --> J[Send Signature<br/>to Server]
J --> K[Server Verifies<br/>with Public Key]
K --> L{Valid?}
L -->|Yes| M[Grant Access]
L -->|No| N[Deny Access]
- Add WebAuthn library:
go get github.com/go-webauthn/webauthn- Initialize WebAuthn:
import "github.com/go-webauthn/webauthn/webauthn"
wconfig := &webauthn.Config{
RPDisplayName: "Example Corp",
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
}
webAuthn, err := webauthn.New(wconfig)- Implement User Model:
type User struct {
ID []byte
Name string
DisplayName string
Credentials []webauthn.Credential
}
func (u User) WebAuthnID() []byte {
return u.ID
}
func (u User) WebAuthnName() string {
return u.Name
}
func (u User) WebAuthnDisplayName() string {
return u.DisplayName
}
func (u User) WebAuthnCredentials() []webauthn.Credential {
return u.Credentials
}
func (u User) WebAuthnIcon() string {
return ""
}
func (u *User) AddCredential(cred webauthn.Credential) {
u.Credentials = append(u.Credentials, cred)
}
func (u *User) UpdateCredential(cred webauthn.Credential) {
for i, c := range u.Credentials {
if string(c.ID) == string(cred.ID) {
u.Credentials[i] = cred
return
}
}
}- Create Registration Endpoint:
func BeginRegistration(w http.ResponseWriter, r *http.Request) {
// Parse request
var req struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
}
json.NewDecoder(r.Body).Decode(&req)
// Get or create user
user, err := userStore.GetUser(req.Username)
if err == ErrUserNotFound {
user, err = userStore.CreateUser(req.Username, req.DisplayName)
}
// Generate registration options
options, sessionData, err := webAuthn.BeginRegistration(user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Store session temporarily
sessionID, _ := sessionStore.SaveSession(sessionData)
// Save session ID in cookie
session, _ := cookieStore.Get(r, "webauthn-session")
session.Values["webauthn_session_id"] = sessionID
session.Values["user_id"] = req.Username
session.Save(r, w)
json.NewEncoder(w).Encode(options)
}
func FinishRegistration(w http.ResponseWriter, r *http.Request) {
// Get session
session, _ := cookieStore.Get(r, "webauthn-session")
sessionID := session.Values["webauthn_session_id"].(string)
username := session.Values["user_id"].(string)
// Get user and session data
user, _ := userStore.GetUser(username)
sessionData, _ := sessionStore.GetSession(sessionID)
// Finish registration
credential, err := webAuthn.FinishRegistration(user, *sessionData, r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Save credential to user
user.AddCredential(*credential)
userStore.SaveUser(user)
// Clean up session
sessionStore.DeleteSession(sessionID)
w.WriteHeader(http.StatusOK)
}- Create Authentication Endpoint:
func BeginLogin(w http.ResponseWriter, r *http.Request) {
// Parse request
var req struct {
Username string `json:"username"`
}
json.NewDecoder(r.Body).Decode(&req)
// Get user
user, err := userStore.GetUser(req.Username)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
// Generate login options
options, sessionData, err := webAuthn.BeginLogin(user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Store session
sessionID, _ := sessionStore.SaveSession(sessionData)
// Save session ID in cookie
session, _ := cookieStore.Get(r, "webauthn-session")
session.Values["webauthn_session_id"] = sessionID
session.Values["user_id"] = req.Username
session.Save(r, w)
json.NewEncoder(w).Encode(options)
}
func FinishLogin(w http.ResponseWriter, r *http.Request) {
// Get session
session, _ := cookieStore.Get(r, "webauthn-session")
sessionID := session.Values["webauthn_session_id"].(string)
username := session.Values["user_id"].(string)
// Get user and session data
user, _ := userStore.GetUser(username)
sessionData, _ := sessionStore.GetSession(sessionID)
// Finish login
credential, err := webAuthn.FinishLogin(user, *sessionData, r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// Update credential (sign count, etc.)
user.UpdateCredential(*credential)
userStore.SaveUser(user)
// Clean up WebAuthn session
sessionStore.DeleteSession(sessionID)
// Create authenticated session
delete(session.Values, "webauthn_session_id")
session.Values["authenticated"] = true
session.Save(r, w)
w.WriteHeader(http.StatusOK)
}Initiates the registration process for a new user or adds a credential to an existing user.
Request Body:
{
"username": "user@example.com",
"display_name": "John Doe"
}Response: PublicKeyCredentialCreationOptions (WebAuthn standard format)
Status Codes:
200 OK: Registration options generated successfully400 Bad Request: Invalid request body or missing username500 Internal Server Error: Failed to generate options
Completes the registration process by verifying the credential.
Request Body: PublicKeyCredential with attestation response (WebAuthn standard format)
Response:
{
"status": "success",
"message": "Registration completed successfully"
}Status Codes:
200 OK: Registration completed successfully400 Bad Request: Invalid credential or verification failed401 Unauthorized: Invalid session500 Internal Server Error: Failed to save credential
Initiates the authentication process for an existing user.
Request Body:
{
"username": "user@example.com"
}Response: PublicKeyCredentialRequestOptions (WebAuthn standard format)
Status Codes:
200 OK: Login options generated successfully400 Bad Request: Invalid request or user has no credentials404 Not Found: User not found500 Internal Server Error: Failed to generate options
Completes the authentication process by verifying the assertion.
Request Body: PublicKeyCredential with assertion response (WebAuthn standard format)
Response:
{
"status": "success",
"message": "Login completed successfully",
"user": {
"username": "user@example.com",
"display_name": "John Doe"
}
}Status Codes:
200 OK: Login completed successfully400 Bad Request: Session not found401 Unauthorized: Invalid credential or verification failed500 Internal Server Error: Failed to update credential
Returns information about the currently authenticated user.
Response:
{
"username": "user@example.com",
"display_name": "John Doe",
"credentials": 2
}Status Codes:
200 OK: User information returned401 Unauthorized: Not authenticated404 Not Found: User not found
Logs out the current user by clearing the session.
Response:
{
"status": "success",
"message": "Logged out successfully"
}Status Codes:
200 OK: Logout successful401 Unauthorized: Invalid session500 Internal Server Error: Failed to clear session
Configure the server using these environment variables:
| Variable | Description | Default | Required |
|---|---|---|---|
RP_DISPLAY_NAME |
Display name for your application shown to users | "WebAuthn Demo" |
No |
RP_ID |
Relying Party ID - must match your domain (without protocol/port) | "localhost" |
No |
RP_ORIGIN |
Full origin URL including protocol and port | "http://localhost:8080" |
No |
PORT |
Server port to listen on | "8080" |
No |
Development (localhost):
export RP_DISPLAY_NAME="My WebAuthn App"
export RP_ID="localhost"
export RP_ORIGIN="http://localhost:8080"
export PORT="8080"Production:
export RP_DISPLAY_NAME="My WebAuthn App"
export RP_ID="example.com"
export RP_ORIGIN="https://example.com"
export PORT="443"- RP_ID must match the domain where your app is hosted (e.g.,
example.comorsubdomain.example.com) - RP_ORIGIN must include the full origin with protocol (e.g.,
https://example.com) - HTTPS is required in production - WebAuthn only works over HTTPS (except for localhost)
- Credentials are bound to the RP_ID and cannot be used across different domains
WebAuthn is supported in all modern browsers:
- ✅ Chrome 67+
- ✅ Firefox 60+
- ✅ Safari 13+
- ✅ Edge 18+
- ✅ Opera 54+
Causes:
- User cancelled the authentication prompt
- Timeout expired (default 60 seconds)
- User gesture required but not present
Solutions:
- Ensure the WebAuthn call is triggered by a user action (button click)
- Increase timeout if needed
- Check browser console for specific error details
Causes:
- Not using HTTPS in production
- RP_ID doesn't match the domain
- Origin mismatch
Solutions:
- Use HTTPS in production (localhost is exempt)
- Verify
RP_IDmatches your domain exactly - Check
RP_ORIGINincludes correct protocol and domain
Causes:
- Authenticator doesn't support the requested algorithm
- Platform authenticator not available
- User verification not supported
Solutions:
- Include multiple algorithms in
pubKeyCredParams(e.g., ES256, RS256) - Set
authenticatorAttachmenttoundefinedto allow any authenticator - Make
userVerificationoptional:"preferred"instead of"required"
Causes:
- Credential already exists for this user
excludeCredentialslist contains existing credential
Solutions:
- This is expected behavior - prevents duplicate registrations
- Allow user to add additional credentials instead
- Clear existing credentials if re-registration is needed
Causes:
- User hasn't registered yet
- Username mismatch
- Database/storage issue
Solutions:
- Verify user completed registration successfully
- Check username spelling and case sensitivity
- Verify credentials are being saved to storage
Causes:
- Cookies blocked by browser
- Session expired
- SameSite cookie policy
Solutions:
- Check browser cookie settings
- Verify
SameSiteattribute is set correctly - Use shorter session timeouts for WebAuthn sessions
- Enable verbose logging: Check browser console and server logs
- Test with different authenticators: Try platform (Touch ID) vs roaming (YubiKey)
- Verify RP configuration: Double-check
RP_IDandRP_ORIGINmatch your deployment - Test in incognito mode: Rules out extension interference
- Check WebAuthn support: Visit webauthn.io to test browser support
- WebAuthn requires HTTPS in production (localhost is exempt for testing)
- Use valid SSL/TLS certificates
- Enable HSTS (HTTP Strict Transport Security)
- Use secure, HTTP-only cookies
- Set appropriate
SameSiteattribute (StrictorLax) - Implement session expiration and rotation
- Use cryptographically random session keys (32+ bytes)
cookieStore.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7, // 7 days
HttpOnly: true,
Secure: true, // MUST be true in production
SameSite: http.SameSiteStrictMode,
}- Generate cryptographically random challenges (32+ bytes)
- Challenges must be single-use only
- Implement challenge expiration (60 seconds recommended)
- Never reuse challenges across sessions
- Server must validate origin matches expected value
- RP_ID must match your domain exactly
- Reject requests from unexpected origins
- The
go-webauthnlibrary handles this automatically
- Store public keys securely in database
- Associate credentials with user accounts properly
- Track signature counter to detect cloned authenticators
- Never store private keys (they never leave the authenticator)
- Require user verification for sensitive operations
- Set
userVerification: "required"for high-security scenarios - Understand the difference between user verification and user presence
- Implement rate limiting on registration/login endpoints
- Prevent brute force attacks
- Monitor for suspicious patterns
- This demo uses
attestation: "none"for privacy - Consider
"direct"attestation for high-security enterprise use - Validate attestation statements if using direct/indirect attestation
- Maintain allowlist/blocklist of authenticator models if needed
- Implement account recovery: Provide backup authentication methods
- Allow multiple credentials: Let users register multiple authenticators
- Inform users: Explain what passkeys are and how they work
- Test thoroughly: Test with different browsers and authenticator types
- Monitor and log: Track authentication attempts and failures
- Keep dependencies updated: Regularly update the
go-webauthnlibrary - Implement CSRF protection: Use CSRF tokens for state-changing operations
- Validate all inputs: Never trust client-side data
- Using
attestation: "none"prevents tracking users across sites - Don't log or store personally identifiable information unnecessarily
- Inform users about data collection in privacy policy
- Consider GDPR/privacy regulations in your jurisdiction