Blog

Path to passkeys with Spring Security

By  
Matti Tahvonen
Matti Tahvonen
·
On May 7, 2025 4:49:42 PM
·

There are many ways to utilize passkeys. Handling authentication internally within your app may not be the fanciest way to do it in 2025, but for legacy apps and when aiming for architectural simplicity, handling passkeys by yourself, like handling usernames and passwords, can still be a well-argued option. Spring Security has fairly recently added support for passkeys (via WebAuthn), making it an obvious library to utilize if you, for example, already use it for username-password style authentication.

Currently, the core documentation is a bit sparse, so I decided to document my learnings from a recent migration. I also pushed out these as a full-stack demo application, you can check out and play with (JPA for persistence, Vaadin for UI, but should be easily adaptable for other technologies). The demo app is pretty much deployment-ready, so you can even configure it for your test domain and try it in action.

The git history of the demo app also documents a potential path you could, or maybe even should, take with your app:

  1. Add a One-Time-Token (OTT) based “backup login” method to your app. There is a high chance that you already kind of have this within your “reset password” functionality.
  2. Add passkey support as an alternative to username-password authentication
  3. Urge people to move over to passkey-based authentication
  4. Finally, remove the option of username-password authentication altogether

Passkey support in Spring Security is somewhat limited, in that it can’t be used as the sole authentication mechanism. At least I couldn’t find an easy way to implement registering passkeys to an account without first logging in via some other mechanism. Since it's always good to have an account recovery method in place, I suggest offering OTP alongside passkeys. In the demo app, OTT functionality is implemented in a demo-friendly manner, showing you the OTT link directly in the web browser if you just know the username. In your real-world app, be sure to send login tokens/URLs only to verified email, SMS, or similar!

Before jumping to copy-pasting the code to your own app, try to understand the “math” behind passkey authentication and how Spring Security copes with it. I recommend playing with the example app locally,  and if you are not familiar with WebAuthn, I suggest reading my previous article or the WebAuthn docs from MDN.

Defining relaying party parameters

In Spring Security, the “relaying party parameter” is defined by defining a bean for WebAuthnRelyingPartyOperations. You’ll need an ID (which is essentially your domain), name, and allowed origin (URL). Obviously, during testing and deployment, at least the first and last ones are different. Thus, it is handy to make them, for example, configurable with the @Value annotation

@Bean public WebAuthnRelyingPartyOperations relyingPartyOperations(
            PublicKeyCredentialUserEntityRepository userEntities,
            UserCredentialRepository userCredentials,
            @Value("${webauthn.id}") String webauthnId,
            @Value("${webauthn.origin}") String webauthnOrigin) {

        return new Webauthn4JRelyingPartyOperations(userEntities, userCredentials,
                PublicKeyCredentialRpEntity.builder()
                        .id(webauthnId)
                        .name("}> WebAuthn + Spring Security Demo").build(),
                Set.of(webauthnOrigin));
    }

As the example uses the awesome Testcontainers to automatically launch a RDBMS, an application class from the test sources is used for local testing. Thus, localhost and http://localhost:8080 are defined in src/test/resources/application.properties. Alternatively, you could use, e.g., environment variables or Spring profiles to define them for both development and testing.

What to persist and where

As an application developer, you need to persist certain bits related to passkeys. The documentation can make this look easier than it really is. Spring Security comes with a couple of interfaces/beans you need to implement to make your users' passkeys also work after your first application update. The default/built-in implementations only save passkeys in the app-server memory, so they are really suitable only for demos or learning how things work. Mappings from webauthn user IDs to your actual user object and the actual passkeys need to be properly persisted!

A small oddity with the persistence is the custom Bytes class that Spring Security uses. That is used in various places to store, well, bytes. If you ask me, I think the API would have been easier to grasp and use if it were simply a byte[], which you probably need in your domain objects or persistence implementation. But it is not that complex after all, you can get those bytes from Bytes using getBytes() method 😂 

Below you will find the relevant interfaces/beans and a bit more thorough explanation on when they are called and how you should implement them:

PublicKeyCredentialUserEntityRepository

WebAuthn API uses an ID to identify users in the system. The purpose of this fancy-named repository is essentially to map between your own user objects and identifiers (“Bytes”) used by Webauthn. You’ll need to map your own users to PublicKeyCredentialUserEntity instances that the Spring Security can handle.

Optimally, the Webauthn ID could be the actual “primary key” in your own system, and your user objects would implement PublicKeyCredentialUserEntity. I’m doing it this way in a fresh hobby project where I’m not that strict on architectural layers. This approach makes “mapping” quite straightforward, and it essentially becomes a trivial lookup from the DAO. But most likely you already have other domain objects and identifiers in place. Also, the PublicKeyCredentialUserEntity interface, used by Spring Security in the API, probably has method collisions with your existing method names, and you might not want to use the already mentioned Bytes object in your own user class. Thus, you need to adapt your data structures so that you can find your users with Bytes and their Bytes (id) with username. 

In the example, which I derived from the map based in-memory implementation, I added a simple byte[] webauthnId field to the JPA entity representing the user for this purpose. In most cases, though, deriving the webauthn ID from some final field is probably a better solution. I noticed this gotcha with the API afterwards, but you can provide the webauthnId, even if the framework hasn’t yet called the save method (with its random generated id). If, for example, your username is immutable and less than 64 bytes long, you could also derive the identifier from the username. Or, simply derive it from the primary key of your user entity, if you have one in your RDBMS. With this approach, the save method actually becomes obsolete, and it is only called for “anonymousUser”, which is probably a bug. 

For a reason unknown to me, you should notice that methods may also be hit with “anonymousUser” or non-existent identifiers during login, so be sure to include null/empty checks in your code.

Methods:

  • PublicKeyCredentialUserEntity findById(Bytes id)
    • During a login, after the passkey has been validated, Spring Security calls this method to find the username for this (Webauthn) user ID. In my demo, I query the DB with byte[], but you could also, e.g., convert the ID to username or primary key instead. 
  • PublicKeyCredentialUserEntity findByUsername(String username)
    • This method is used during registration to find an ID (and the rest of PublicKeyCredentialUserEntity) for the browser. In my example, by trial and error, I started to return null in case the ID is not created. The save method then gets called later on with a randomly generated ID (in PublicKeyCredentialUserEntity). In hindsight, one could also generate one in this method on demand and persist it. This is kind of what happens if you choose to generate an ID based on the username or the primary key. Note that for some unknown reason, it is also bombarded with “anonymousUser” during login.
  • void save(PublicKeyCredentialUserEntity userEntity)
    • Spring Security calls this method when it has created an ID for a user.  You should find a relevant/current user and save it for him (or maintain some mapping). In case you derive the ID from e.g. your username or primary key from DB, or generate missing identifiers on demand in findByUsername, I assume you can leave this empty. Beware that this method is also bombarded with the calls with “anonymousUser”!
  • void delete(Bytes credentialId)
    • I have no idea who actually uses this and why. I implemented this in my demo, but you can probably just omit this (and have an empty method body).

UserCredentialRepository

The purpose of this interface is to handle persisting and maintaining the actual passkeys (~ CredentialRecord’s). A single logical user in your web app can have multiple passkeys, e.g., you could have one in the Apple ecosystem and one in Google Passwords Manager for your Android tablet. Although I find this interface a bit better quality, there are a couple of gotchas here as well that might not be obvious based on method names (and limited docs).

Methods:

  • void save(CredentialRecord credentialRecord)
    • Spring Security calls this method when it is registering a new passkey, but also after a login. CredentialRecord contains details like last used, which may need to be updated. In my solution, I created a JPA entity WebAuthnRecord to persist CredentialRecord instances, with some helper. As I wasn’t interested in all the fields, the lazy developer in me serialized the CredentialRecord to JSON, and I only created actual DB columns for fields that are needed for lookup.
  • CredentialRecord findByCredentialId(Bytes credentialId)
    • Finds the passkey details by its identifier. This gets called during login, after the passkey is validated by Spring Security
  • List<CredentialRecord> findByUserId(Bytes userId)
    • This method finds current passkeys for the user identified by the WebAuthn ID. This is used, e.g., in the passkey management view (either by you or the default provided by DefaultWebAuthnRegistrationPageGeneratingFilter), but also these are used when building the “options request” during passkey registrations (some hints for the browser). Note that, like some methods inPublicKeyCredentialUserEntityRepository, this method seems to be also called by the framework (invalidly) for a non-logged in user, with a random id. 
  • void delete(Bytes credentialId)
    • This method is only used for removing passkeys. It is used by the default UI (WebAuthnRegistrationFilter) provided by Spring Security. In the final example I built, where the passkeys are managed via a Vaadin-built profile page, this method is actually obsolete (it was easier to delete directly via the persisted object).

PublicKeyCredentialCreationOptionsRepository

Although the default docs don’t mention this at all, a third thing regarding persistence to at least be aware of is PublicKeyCredentialCreationOptionsRepository. The purpose of the class is to hold the server secret for validation while authenticating or registering a passkey. The default is usually just fine, where the PublicKeyCredentialRequestOptions is stored in the HTTP session. But in case you intend to make your app fully server-stateless, you probably need to implement this in another manner (declare your own bean in this case and somehow, e.g., save in shared cache or temporarily to the database).

As the example is using Vaadin, and my strong suggestion with Vaadin is to use session affinity, the default works just fine for the demo, even if you’d deploy the app on a cluster.

Customising login and registration screens

If you have the above hooks properly set up, you might have all the required pieces in place to start utilizing passkeys. Spring Security automatically enhances the default “form-based login” view with an option to sign in with passkeys. Also, there is a rudimentary page to maintain users' passkeys mapped to /webauthn/register. In many cases, you probably want to override these with something that better fits your requirements (e.g., visuals or UX match with the rest of your application).

In my demo application, these views are implemented with Vaadin. From the original “start state”, I dropped the usage of Vaadin LoginForm web component as that is (at least currently) tied to username-password style login. Instead, the Login view is now implemented using a simple composition of buttons (for both Passkey and OTT authentication) and a layout. Clicking the “Use passkey” button executes a JS function derived from Spring Security’s built-in login page and initiates the login sequence. Otherwise, the actual login process relies on existing default filters by Spring Security.

Screenshot: Login view in an app using passkeys essentially contains only a single button. OTT-button is left for backup, but text fields for username and password.

On the passkey modification functionality, in the Profile view, I actually went a bit further away, mostly for “academic purposes”. In the registration, I actually generate the “challenge” directly from the Vaadin UI logic and save it to the view object. This avoids an extra request to the server when registering a new passkey.

The Spring Security docs quite well cover the endpoints you need to use if you plan to customize login and registration pages yourself.

Summary

Adding passkey support to a Spring Security-based application is very much achievable—especially with the recent additions to the framework. While some of the defaults are demo-oriented, the hooks for production-grade integration are all there. This article, together with the demo app, walks through:

  • Integrating WebAuthn into a Spring Security flow
  • Persisting passkeys and user identifiers
  • Providing a backup login mechanism (OTT)
  • Customizing login and registration experiences

Passkeys represent a big step forward in both usability and security. With a bit of care around persistence and user experience, you can bring modern authentication to your Java app without outsourcing identity to a separate service. Give the demo a spin and see how it fits into your stack.

Matti Tahvonen
Matti Tahvonen
Matti Tahvonen has a long history in Vaadin R&D: developing the core framework from the dark ages of pure JS client side to the GWT era and creating number of official and unofficial Vaadin add-ons. His current responsibility is to keep you up to date with latest and greatest Vaadin related technologies. You can follow him on Twitter – @MattiTahvonen
Other posts by Matti Tahvonen