Complete Java Passkey Implementation: Registration, Auth, Anti-Replay & Multi-Device

This article provides a complete Java implementation guide for Passkey passwordless authentication, covering credential registration with challenge generation, public key storage, signature verification, anti-replay protection via one-time challenges, multi-device credential management, account recovery considerations, Spring Security WebAuthn integration, and a layered authentication architecture separating auth methods from session management.

LuTiao Programming
LuTiao Programming
LuTiao Programming
Complete Java Passkey Implementation: Registration, Auth, Anti-Replay & Multi-Device

Passkey vs Traditional Password Login

Passkey authentication replaces shared secrets (passwords) with public-key cryptography. The user's device holds the private key; the server stores only the public key. During login, the server sends a random challenge, the device signs it with the private key (after user verification via Face ID, Touch ID, Windows Hello, or PIN), and the server verifies the signature with the stored public key. Biometric data never leaves the device. This shifts verification from "what the user knows" to "what the user possesses." Even if the database leaks, attackers only get public keys, not reusable credentials.

Registration Flow: Creating Credentials

Registration occurs after the user has already authenticated (via password, SMS, or QR code). The frontend calls POST /api/passkeys/register/options. The server generates registration parameters including a cryptographically random challenge (32 bytes via SecureRandom), rp (relying party), user, pubKeyCredParams, and authenticatorSelection. The challenge must be single-use and stored temporarily (e.g., Redis key webauthn:register:challenge:{sessionId} with a few-minute TTL) to bind the registration response to this specific request.

private static final SecureRandom RANDOM = new SecureRandom();
public byte[] generateChallenge() {
    byte[] challenge = new byte[32];
    RANDOM.nextBytes(challenge);
    return challenge;
}

The browser invokes navigator.credentials.create({ publicKey: options }), prompting the user for biometric/PIN confirmation. The authenticator generates a key pair; the private key never leaves the authenticator. The browser returns the attestation object containing the public key and credentialId to the server.

Server-Side Storage

The server must persist more than just the public key. A typical PasskeyCredential entity includes:

public class PasskeyCredential {
    private Long id;
    private Long userId;
    private String credentialId;
    private byte[] publicKey;
    private long signCount;
    private String transports;
    private String deviceName;
    private Instant createdAt;
    private Instant lastUsedAt;
}

Database schema:

CREATE TABLE user_passkey (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    credential_id VARCHAR(512) NOT NULL,
    public_key TEXT NOT NULL,
    sign_count BIGINT NOT NULL DEFAULT 0,
    transports VARCHAR(255),
    device_name VARCHAR(255),
    created_at TIMESTAMP NOT NULL,
    last_used_at TIMESTAMP,
    UNIQUE KEY uk_credential_id (credential_id)
);
credentialId

is critical because a user can have multiple Passkeys (MacBook, iPhone, Windows PC, security key). The server must look up the public key by credentialId during authentication, so the data model is User → multiple Credentials, not a single key per user.

Registration Verification: Challenge & Origin Checks

Upon receiving the credential, the server must verify: challenge matches the one just issued origin is an allowed site rpId is correct

Credential data is structurally valid

WebAuthn's security model binds credentials to a specific Relying Party and origin. The author strongly advises against hand-rolling the binary parsing and verification logic; instead, use spring-security-webauthn which implements the full protocol correctly.

Authentication (Login) Flow

For login, the frontend calls POST /api/passkeys/authenticate/options. The server generates a fresh random challenge, stores it (e.g., 3-minute TTL), and returns it with the rpId. The browser calls navigator.credentials.get({ publicKey: options }), the user verifies via biometric/PIN, and the device signs the challenge data with the registered private key. The server looks up the public key by the returned credentialId, verifies the signature, and checks challenge, origin, and RP ID. Simplified:

Server: Prove you hold the private key.
Device: Here is the signature for your random Challenge.
Server: Public key verification passed, login allowed.

Compared to passwords ("tell me the secret you know"), Passkey proves possession of a cryptographic credential only the user can use.

Why Every Login Needs a New Challenge (Anti-Replay)

Without a per-login challenge, an attacker who captures a valid credentialId + signature could replay it. The server generates challenge = random() each time; the signature binds to that specific challenge. Challenge A produces Signature A; Challenge B requires Signature B. A captured Signature A cannot answer Challenge B. After successful verification, the challenge must be atomically consumed (read + delete) to prevent reuse under concurrency. Redis GETDEL or a Lua script ensures atomic consumption, similar to Refresh Token Rotation. Never use separate get() then delete() — concurrent requests could both succeed.

Challenge challenge = challengeRepository.consume(challengeId); // atomic read+delete

Unified Session & Token Management

Passkey authentication does not replace the session system. After verification, the flow merges with other auth methods:

Passkey verified
    ↓
Obtain userId
    ↓
Create LoginSession
    ↓
Generate Access Token
    ↓
Generate Refresh Token

All auth methods (PASSWORD, SMS, PASSKEY) return an AuthenticationResult(userId, authMethod) which feeds into a single loginSessionService.create(). This keeps Session, multi-device, forced logout, and token renewal logic independent of the authentication mechanism.

Multi-Device Credential Management

Users need multiple Passkeys across devices. The account security page lists each credential with device name, creation date, and last used timestamp, plus a delete action. Deletion must enforce ownership:

DELETE FROM user_passkey WHERE credential_id = ? AND user_id = ?;

Not just WHERE credential_id = ?. This prevents users from deleting other users' credentials.

Protecting the Last Passkey (Account Recovery)

If a user has no password, no verified phone, and only one Passkey, deleting it would lock them out permanently. Before deletion, check:

if (passkeyCount == 1 && !user.hasPassword() && !user.hasVerifiedPhone()) {
    throw new BusinessException("Please bind another login method first");
}

The most dangerous login bug isn't unauthorized access — it's locking the legitimate user out. Credential addition/removal must consider the full account recovery chain.

Keeping SMS as a Recovery Fallback

Passkey becomes the primary login method; SMS remains for recovery (device loss, browser changes, enterprise restrictions, sync failures). Login page shows "Use Passkey" first, then "Use SMS". However, the recovery path must not be weaker than the primary auth. High-risk operations (delete all Passkeys, change phone/email, disable MFA) should require re-verification with the current Passkey or SMS plus additional risk checks. The system's security ceiling is defined by its weakest recovery entry point.

Username-Less Login (Discoverable Credentials)

Passkeys enable login without entering a username: the user clicks "Use Passkey", the device shows available credentials, and the selected credential's credentialId lets the server identify the user. Experience becomes: open page → click Passkey → Face ID → logged in. The author recommends starting with the basic flow (login → bind → login with Passkey) before adding discoverable credentials.

Spring Security WebAuthn Integration

Spring Security now provides org.springframework.security:spring-security-webauthn. Configuration example:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .webAuthn(webAuthn ->
            webAuthn
                .rpId("example.com")
                .allowedOrigins("https://example.com")
        );
    return http.build();
}

The library handles registration and authentication assertions. Production projects should leverage this mature implementation and focus engineering effort on business-layer concerns: Passkey binding permissions, device listing, deletion, account recovery, session creation, risk control, and audit logging.

Audit Logging

Log key events with timestamps, device names, and credential IDs (but never full challenges, signatures, or sensitive credential data):

2026-11-02 09:12:31 User added Passkey Device: MacBook Pro credentialId: xxx
2026-11-02 09:15:07 Passkey login success Device: MacBook Pro
2026-11-02 10:20:11 User deleted Passkey Device: iPhone

Also log failures: unknown credential, challenge verification failure, origin mismatch. Detailed logs drastically reduce debugging time given the many moving parts (Challenge, Credential, Authenticator, Signature, RP, Origin).

Layered Authentication Architecture

The complete login system now supports Password, SMS, QR Code, and Passkey. Complexity doesn't multiply because each method only handles the first layer: "prove who you are." All methods converge on a unified model:

Auth Method
    ↓
AuthenticationResult
    ↓
LoginSession
    ↓
Access Token + Refresh Token
    ↓
Multi-device, Single-sign-out, Forced logout, Silent renewal

Five distinct layers emerge:

Identity Proof — Password, SMS, QR, Passkey (pluggable)

Session Management — LoginSession, multi-device, forced logout

Short-lived Access — Access Token

Long-lived Persistence — Refresh Token

Account Recovery — Regaining access when primary factors are lost

Stuffing all layers into a single JWT or login() method creates rigidity. Passkey reinforces that authentication methods should be swappable; the session layer is the stable core.

Rollout Strategy

Allow already-logged-in users to bind Passkeys.

Add "Use Passkey Login" button on the login page.

Build Passkey list, device deletion, account recovery, anomaly audit.

Once stable, promote Passkey to default login method.

The hard part isn't running WebAuthn — it's designing what happens when the user loses their phone. Only when that recovery flow is solid does Passkey become a complete login solution.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaSpring SecurityWebAuthnPasskeyaccount recoveryPasswordless AuthenticationChallenge-ResponseMulti-Device Management
LuTiao Programming
Written by

LuTiao Programming

LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.