Passkey Instead of Passwords: How It Works and Integration

1. Overview

Passwords have three major problems: they’re easy to guess, reused across sites, and easy to trick users into entering on fake pages. Passkeys solve all three through public key cryptography. The server no longer holds any of the user’s secrets.

This article explains what passkeys are and how the registration and login flows work. Then you’ll enable passkeys for a Spring Boot 3.x application using Spring Security. At the end, we’ll cover common errors and cases where passkeys shouldn’t be used alone.

2. What is Passkey?

A passkey is a WebAuthn credential—a credential that follows the FIDO2 standard. Essentially, it’s a pair of keys:

  • Private key lives on the user’s device (phone, laptop, USB security key) or in a password manager. This key never leaves that location.
  • Public key is sent to the server during registration. The server stores this key with the user’s account.

Each key pair is tied to a Relying Party (RP)—your website or application—through an RP ID. The RP ID is typically the domain name, like shop.example.com.

There are two main types of passkeys:

Type Details
Synced passkey Synced across cloud accounts managed by the OS or password manager. You can recover it if you lose the device.
Device-bound passkey Exists only on one device, like a hardware security key. More secure, but losing the device means losing the key.

Users unlock a passkey using their fingerprint, face, or the device’s PIN. Biometric data is used only for local unlocking. It is never sent to the server.

3. How Passkeys Work

3.1. Registration

  1. Server generates a random challenge. It sends the challenge along with RP info and user details to the browser.
  2. The browser calls navigator.credentials.create(). Authenticator generates a new key pair for that specific RP ID.
  3. The browser sends the public key, credential ID, and signed data back to the server.
  4. Server verifies the challenge and origin, then stores the public key.

3.2. Authentication

  1. Server generates a new challenge.
  2. The browser calls navigator.credentials.get(). Authenticator signs the challenge with the private key.
  3. Server uses the stored public key to verify the signature.

The core principle is “sign with private key, verify with public key”. The pure Java code below demonstrates exactly this step:

import java.security.*;

public class PasskeyConcept {

    public static void main(String[] args) throws Exception {
        // Device generates a key pair during registration (EC P-256, equivalent to the common ES256 in WebAuthn)
        KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
        generator.initialize(256);
        KeyPair deviceKeys = generator.generateKeyPair();
        PublicKey storedOnServer = deviceKeys.getPublic();

        // Server generates a random challenge for authentication
        byte[] challenge = randomBytes();

        // Device signs the challenge with its private key
        Signature signer = Signature.getInstance("SHA256withECDSA");
        signer.initSign(deviceKeys.getPrivate());
        signer.update(challenge);
        byte[] signature = signer.sign();

        // Server verifies using the stored public key
        System.out.println("Challenge correct: " + verify(storedOnServer, challenge, signature));
        System.out.println("Challenge wrong: " + verify(storedOnServer, randomBytes(), signature));
    }

    static boolean verify(PublicKey key, byte[] data, byte[] signature) throws Exception {
        Signature verifier = Signature.getInstance("SHA256withECDSA");
        verifier.initVerify(key);
        verifier.update(data);
        return verifier.verify(signature);
    }

    static byte[] randomBytes() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        return bytes;
    }
}

Output:

Challenge correct: true
Challenge wrong: false

From this result, you can draw two conclusions:

  • Server only keeps storedOnServer, which is the public key. Even if an attacker steals the database, they can’t use it to sign in.
  • The signature is valid only for that specific challenge. If an attacker captures an old signature and replays it, they still can’t pass verification.

In real WebAuthn, the signed data also contains the origin of the page. The browser fills in the origin; the webpage can’t forge it. So if a user accidentally visits examp1e.com, the browser won’t use the passkey for example.com. Phishing is blocked at the protocol level, regardless of user vigilance.

4. Why Passkeys Are Getting Attention

  • Phishing-resistant by design. Passwords and OTPs can both be tricked into being entered on fake pages. Passkeys cannot be.
  • No secrets on the server. Database leaks don’t expose login credentials.
  • Better user experience. Users just tap their fingerprint instead of remembering a password or waiting for SMS.
  • The ecosystem is ready. Major operating systems, browsers, and password managers all support passkey sync. This means losing a device is no longer the barrier it once was.
  • Frameworks have built-in support. You don’t have to write the cryptographic verification yourself, which is error-prone.

5. Integrating Passkeys with Spring Security

Starting with Spring Security 6.4 (bundled with Spring Boot 3.4 and later), the framework includes a webAuthn() DSL. Under the hood, verification uses the WebAuthn4J library.

5.1. Dependency

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>com.webauthn4j</groupId>
        <artifactId>webauthn4j-core</artifactId>
        <version>${webauthn4j.version}</version>
    </dependency>
</dependencies>

Declare the webauthn4j.version property with a version compatible with your Spring Security version. The Spring Security documentation specifies tested versions.

5.2. Configure SecurityFilterChain

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .webAuthn(webAuthn -> webAuthn
                .rpName("Nón Lá Shop")
                .rpId("localhost")
                .allowedOrigins("http://localhost:8080")
            );
        return http.build();
    }

    @Bean
    UserDetailsService userDetailsService() {
        UserDetails customer = User.withUsername("minh")
                .password("{noop}matkhau-tam")
                .roles("CUSTOMER")
                .build();
        return new InMemoryUserDetailsManager(customer);
    }
}

Each part means:

  • rpName: display name shown in the OS passkey dialog.
  • rpId: the domain bound to the passkey. This value has no scheme or port. In production, it would be shop.example.com.
  • allowedOrigins: the allowed origins. Origins must match exactly, including scheme and port.
  • formLogin: still keep it. Users need to sign in with a password first to register a passkey. A passkey is an additional credential added to an existing account.

When the app runs, Spring Security automatically creates these endpoints:

GET  /webauthn/register              passkey management/registration page (default)
POST /webauthn/register/options      generate registration challenge
POST /webauthn/register              save new public key
POST /webauthn/authenticate/options  generate authentication challenge
POST /login/webauthn                 verify signature and sign in

The default login page will have an added “Sign in with a passkey” button. The test flow consists of four steps:

  1. Sign in as minh / matkhau-tam.
  2. Open /webauthn/register and create a passkey.
  3. Sign out.
  4. Sign back in with the passkey.

5.3. Storing Credentials

By default, Spring Security stores credentials in memory. Restarting the app loses all passkeys. In production, you need to provide beans that implement two interfaces:

  • UserCredentialRepository: store public key, credential ID, and signature count.
  • PublicKeyCredentialUserEntityRepository: map your users to WebAuthn user handles.

Recent Spring Security versions include JDBC implementations for both interfaces. You can also write your own using JPA. Each credential should be a separate record because a user typically has multiple passkeys across multiple devices.

6. Browser Side

If you use the default login page, Spring Security handles the JavaScript for you. With a custom interface, the login flow looks like this:

async function loginWithPasskey() {
  const csrf = document.querySelector('meta[name="_csrf"]').content;

  // 1. Request challenge from the server
  const optionsRes = await fetch('/webauthn/authenticate/options', {
    method: 'POST',
    headers: { 'X-CSRF-TOKEN': csrf }
  });
  const options = PublicKeyCredential.parseRequestOptionsFromJSON(await optionsRes.json());

  // 2. Browser shows dialog, authenticator signs the challenge
  const credential = await navigator.credentials.get({ publicKey: options });

  // 3. Send signature to server for verification
  const res = await fetch('/login/webauthn', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
    body: JSON.stringify(credential.toJSON())
  });
  if (res.ok) window.location.href = '/';
}

Steps in the code:

  • Step 1 fetches the challenge. The request must include a CSRF token since this is a POST.
  • parseRequestOptionsFromJSON converts base64url fields to ArrayBuffer. Older browsers may not support this function. In that case, you need a helper library or manual decoding.
  • Step 3 sends the result to the server. The JSON format must match what the server expects. The script in Spring Security’s default login page is the best reference to compare against.

7. Testing

You can’t easily mock a real authenticator in a unit test. However, you can still test the important server-side parts: the challenge creation endpoint works and each call returns a different challenge.

@SpringBootTest
@AutoConfigureMockMvc
class PasskeyEndpointTest {

    @Autowired
    MockMvcTester mvc;

    @Test
    void authenticationOptions_containRpIdAndFreshChallenge() {
        String first = requestChallenge();
        String second = requestChallenge();

        assertThat(first).isNotBlank();
        assertThat(second).isNotEqualTo(first);
    }

    @Test
    void authenticationOptions_useConfiguredRpId() {
        assertThat(mvc.post().uri("/webauthn/authenticate/options").with(csrf()))
                .hasStatusOk()
                .bodyJson()
                .extractingPath("$.rpId")
                .isEqualTo("localhost");
    }

    private String requestChallenge() {
        MvcTestResult result = mvc.post()
                .uri("/webauthn/authenticate/options")
                .with(csrf())
                .exchange();
        assertThat(result).hasStatusOk();
        return JsonPath.read(result.getResponse().getContentAsString(), "$.challenge");
    }
}

MockMvcTester (Spring Framework 6.2+) lets you write assertions in AssertJ style. csrf() comes from SecurityMockMvcRequestPostProcessors. The first test confirms the challenge isn’t reused—this is the replay attack prevention mechanism. The second test ensures rpId is configured correctly. This is the most common configuration error during deployment.

For end-to-end testing, use the browser’s virtual authenticator. Chrome DevTools has a WebAuthn tab, and WebDriver/Playwright also support this feature.

8. Common Errors and When Not to Use

8.1. Common Errors

  • Wrong rpId or allowedOrigins. Writing https:// in rpId or forgetting the port in origin causes the browser or server to reject it. Extract these values to application.yaml per environment.
  • Not using HTTPS. WebAuthn only runs in a secure context. localhost is the only exception.
  • Changing the domain. Passkeys are tied to the RP ID. Changing the main domain makes all old passkeys unusable.
  • Storing in-memory in production. A restart loses users’ passkeys.
  • Only allowing one passkey registration. Users need multiple passkeys for multiple devices. Provide a management page and allow deleting individual passkeys.

Note: Passkeys are only strong when account recovery is also strong. If “forgot passkey” just sends an email link, attackers will target email instead.

8.2. When Not to Use (or Not Alone)

  • Users on old or shared devices, like a checkout computer or kiosk. Keep a backup method in this case.
  • Systems not browser or app-based, like inter-service APIs, CLIs, or background jobs. These should use OAuth2 client credentials, mTLS, or API keys.
  • Compliance requires hardware keys only. Synced passkeys might not qualify. You need device-bound passkeys and attestation verification.
  • Team lacks account recovery support processes. First implement passkey as an optional addition, then consider dropping passwords.

9. Conclusion

Passkeys replace shared secrets (passwords) with public key pairs bound to a domain. This single mechanism eliminates phishing, credential stuffing, and database breach risks. With Spring Security, just add webAuthn() to SecurityFilterChain, configure rpId and allowedOrigins correctly, and replace the in-memory credential store with a persistent one.

The safest deployment approach is step-by-step: let users add passkeys to existing accounts, design a solid recovery flow, then consider disabling password login.

passkeywebauthnspring-securitysecuritytech-trends