“How do I add passkeys to my Vaadin application?” is a question I have been asked many times. Spring Security has supported WebAuthn since version 6.4, but there was no example of how to use it with Vaadin Flow. So I built one.

The full source code is on GitHub: simasch/vaadin-webauthn

It uses Vaadin 25.3, Spring Boot 4.1, and Spring Security 7.1.

What the example does

  1. You sign in with a username and password.
  2. You register one or more passkeys: Touch ID, Windows Hello, your phone, or a security key.
  3. From then on, you can click Sign in with a passkey on the login view, and no password is needed.
  4. You can see your passkeys in a Grid and delete the ones you no longer need.

The Spring Security configuration

Spring Security does the hard part: it creates the challenges, verifies the signatures, and stores the credentials. You switch it on with http.webAuthn():

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
                                        SecurityContextHolderStrategy securityContextHolderStrategy) throws Exception {
    SecurityContextHolder.setContextHolderStrategy(securityContextHolderStrategy);

    return http
            .authorizeHttpRequests(auth -> auth.requestMatchers("/webauthn/**").authenticated())
            .webAuthn(webAuthn -> webAuthn
                    .rpId("localhost")
                    .rpName("Vaadin WebAuthn Demo")
                    .allowedOrigins("http://localhost:8080")
                    .disableDefaultRegistrationPage(true))
            .with(VaadinSecurityConfigurer.vaadin(), vaadin -> vaadin.loginView(LoginView.class))
            .build();
}

You need the spring-security-webauthn dependency, which brings in WebAuthn4J:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-webauthn</artifactId>
</dependency>

webAuthn() adds four endpoints:

Endpoint Purpose
POST /webauthn/register/options Get the options to create a passkey
POST /webauthn/register Verify and store the new passkey
POST /webauthn/authenticate/options Get the options to sign in
POST /login/webauthn Sign in with the passkey

I disabled the default registration page because Vaadin provides the UI.

The configuration has two lines that you will not find in the Spring Security documentation. Both are needed because of how Vaadin and Spring Security work together.

Vaadin detail 1: The SecurityContextHolderStrategy

Vaadin uses its own VaadinAwareSecurityContextHolderStrategy, which makes the security context available in Vaadin’s background threads and push requests. Vaadin installs it as the global strategy only after all singletons are created.

The WebAuthn filters read the global strategy when they are constructed, which happens earlier. PublicKeyCredentialCreationOptionsFilter has no setter to change it later. So the filter looks in the wrong place, finds no authenticated user, and answers /webauthn/register/options with 400 Bad Request.

The fix is to install Vaadin’s strategy yourself before building the filter chain:

SecurityContextHolder.setContextHolderStrategy(securityContextHolderStrategy);

Vaadin detail 2: Authorization for /webauthn/**

WebAuthnRegistrationFilter runs after the AuthorizationFilter. Vaadin’s default rules deny every URL that is not a view or a Vaadin resource. So storing the passkey fails with 403 Forbidden unless you allow the endpoints for authenticated users:

.authorizeHttpRequests(auth -> auth.requestMatchers("/webauthn/**").authenticated())

Why we need JavaScript

Vaadin Flow lets you write the whole UI in Java, so why is there a JavaScript file?

The authenticator talks only to the browser. Creating and using a passkey requires navigator.credentials.create() and navigator.credentials.get(). The browser also adds the page’s origin to what the authenticator signs, which is what makes passkeys resistant to phishing. None of this can happen on the server.

Modern browsers have helpers that convert between the JSON that Spring Security sends and the binary data the WebAuthn API needs. With them, the whole client fits in a few lines:

async function post(url, csrf, body) {
  const response = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', [csrf.headerName]: csrf.token },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!response.ok) {
    throw new Error(`${url} answered HTTP ${response.status}`);
  }
  return response.json();
}

async function register(csrf, label) {
  const options = await post('webauthn/register/options', csrf);
  const credential = await navigator.credentials.create({
    publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
  });
  const result = await post('webauthn/register', csrf, { publicKey: { label, credential: credential.toJSON() } });
  if (!result.success) {
    throw new Error('The server rejected the passkey');
  }
  return true;
}

async function authenticate(csrf) {
  const options = await post('webauthn/authenticate/options', csrf);
  const credential = await navigator.credentials.get({
    publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
  });
  const result = await post('login/webauthn', csrf, credential.toJSON());
  if (!result.authenticated) {
    throw new Error('Passkey sign-in failed');
  }
  return result.redirectUrl;
}

window.passkeys = { register, authenticate };

These helpers need Chrome or Edge 129+, Firefox 119+, or Safari 18.4+.

The file lives in src/main/frontend/webauthn.js and is loaded with @JsModule("./webauthn.js") on the views that use it.

Calling it from Java

executeJs() waits for a returned Promise and passes its result back to Java. That makes the Vaadin side very small. This is the passkey sign-in in the LoginView:

private void signInWithPasskey() {
    getElement().executeJs("return window.passkeys.authenticate($0)", CsrfSupport.currentToken())
            .then(String.class,
                    redirectUrl -> UI.getCurrent().getPage().setLocation(redirectUrl),
                    error -> Notification.show("Passkey sign-in failed: " + error)
                            .addThemeVariants(NotificationVariant.ERROR));
}

The WebAuthn endpoints are protected against CSRF, so the JavaScript needs the token. Spring Security puts it in a request attribute, and Vaadin’s requests pass through the CSRF filter too, so you can read it during any Vaadin request:

public static ObjectNode currentToken() {
    VaadinServletRequest request = VaadinServletRequest.getCurrent();
    CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
    ObjectNode json = JsonNodeFactory.instance.objectNode();
    json.put("headerName", token.getHeaderName());
    json.put("token", token.getToken());
    return json;
}

Registering a passkey works the same way. When the Promise resolves, the Grid is refreshed:

getElement().executeJs("return window.passkeys.register($0, $1)", CsrfSupport.currentToken(), label.getValue())
        .then(Boolean.class,
                ok -> {
                    label.clear();
                    refresh();
                    Notification.show("Passkey registered").addThemeVariants(NotificationVariant.SUCCESS);
                },
                error -> Notification.show("Registration failed: " + error)
                        .addThemeVariants(NotificationVariant.ERROR));

Listing and deleting passkeys

To show the user’s passkeys, declare the two repositories as beans. WebAuthnConfigurer picks them up, and you can inject them into your own service:

@Bean
PublicKeyCredentialUserEntityRepository userEntityRepository() {
    return new MapPublicKeyCredentialUserEntityRepository();
}

@Bean
UserCredentialRepository userCredentialRepository() {
    return new MapUserCredentialRepository();
}

The PasskeyService looks up the user’s WebAuthn user handle by username and then loads the credentials:

public List<Passkey> findPasskeys(String username) {
    PublicKeyCredentialUserEntity userEntity = userEntities.findByUsername(username);
    if (userEntity == null) {
        return List.of();
    }
    return userCredentials.findByUserId(userEntity.getId()).stream()
            .map(PasskeyService::toPasskey)
            .toList();
}

Before deleting, it checks that the credential belongs to the current user.

Before you go to production

  • Persistence: The example keeps the passkeys in memory, so they are lost when the application restarts. Spring Security includes JdbcUserCredentialRepository and JdbcPublicKeyCredentialUserEntityRepository.
  • Relying party ID and origin: rpId must be your domain, without scheme or port, and allowedOrigins must match the URL in the browser exactly. WebAuthn only works on localhost or over HTTPS.
  • Use localhost, not 127.0.0.1, when you try it locally.

Conclusion

Spring Security does the cryptography and verification, and Vaadin provides the UI. Connecting them takes a few lines of JavaScript, a CSRF token, and two configuration details that are specific to Vaadin.

Try it yourself: github.com/simasch/vaadin-webauthn