What is a passkey?
A password is something a person remembers and types. A passkey is a secret their devices hold, unlocked with a fingerprint, a face, or a device PIN.
Concretely, a passkey replaces a password with a pair of cryptographic keys. When a user registers one, their device generates that pair:
The public key is sent to your server, and that is all you ever store.
The private key is never sent to your server. For a device-bound passkey, it remains on the authenticator. For a synced passkey, the credential material may be securely synchronized or transferred between a user's devices by the passkey provider.
To sign in, your server sends a random value and the device signs it with the private key, but only after the user proves they are present with a fingerprint, a face scan, or a device PIN. Your server verifies that signature against the public key it already holds. No secret travels over the wire in either direction.
That changes several things at once:
- Nothing to steal from your database. Public keys are public. A dump of your credentials table gives an attacker nothing they can replay.
- Nothing to phish. A passkey is bound to your domain by the browser. A convincing replica on a lookalike domain cannot use it, because the browser will not offer it there.
- Nothing to type or remember. No password field, no reset flow, no complexity rules.
- Nothing to reuse. Every site gets its own key pair, so a breach at one has no effect anywhere else.
- One step, not two. A passkey authenticator can provide passwordless multi-factor authentication in a single user interaction: possession of the credential's private key plus user verification, typically using a device PIN or biometric.
Passkeys are the user-facing name for WebAuthn, a W3C standard that browsers implement through the navigator.credentials API. Spring Security has supported it since 6.4, and this guide uses that support, though not the part most examples reach for.
Passkeys are FIDO credentials used for passwordless authentication. On the web, applications interact with them primarily through the Web Authentication API, or WebAuthn. It is more precise to say:
Passkeys are FIDO credentials that web applications access through WebAuthn.
than to treat "passkey" as merely another name for WebAuthn. WebAuthn is the browser/API standard; passkeys are credentials built on the FIDO/WebAuthn ecosystem.
How WebAuthn works
Your application is the relying party (RP): the party relying on the authenticator's word about who the user is, identified by an RP ID — the domain a credential is permanently bound to.
The authenticator is whatever holds and uses the credential's private key. Depending on the user's setup, that might involve Touch ID, Windows Hello, Android, a password manager acting as a passkey provider, a hardware security key, or cross-device authentication using another device.
Registration and authentication are both WebAuthn ceremonies, but they are not the same cryptographic operation, and conflating them is where most simplified explanations go wrong.
Registration: Adding a Passkey
Authentication: Signing in
In both ceremonies, your server must retain enough state to determine that the response it receives corresponds to the options it issued. That requirement is what a stateless JWT application has to solve for itself, and it's why a separate challenge store shows up in Step 4.
Key concepts
| Relying party | Your application. Identified by rp-id, which is the domain a credential is permanently bound to. |
|---|---|
| Authenticator | The hardware holding the private key: a secure enclave, a TPM, a security key, a phone. |
| Challenge | Random bytes issued at the start of each ceremony. Signing it proves the response is fresh rather than replayed, which is why it must be single-use and held server-side. |
| Attestation | Authenticator-provided evidence associated with credential registration that can convey information about the authenticator. It is carried inside the registration's AuthenticatorAttestationResponse; Most applications, including this one, request none and simply store the key. |
| Assertion | The sign-in response: a signature over the challenge and the client data, verified against the stored public key. |
| Client data | What the browser observed (the challenge, the origin, the ceremony type), included in the signed payload. This is why the origin check cannot be forged by the caller. |
| Discoverable credential | Previously called a resident key. The authenticator stores the account alongside the key, so the browser can offer it before anything is typed. This enables usernameless sign-in, and it is fixed at registration time. |
| User verification (UV) | Whether a PIN or biometric was actually checked, as opposed to mere possession of the device. This distinguishes a passkey that replaces a sign-in from one acting as a second factor. |
| User handle | An opaque identifier passed to the authenticator at registration and returned on every assertion. It is how a usernameless sign-in resolves to an account. |
| Signature counter | A value the authenticator increments per use, for clone detection. Synced passkeys commonly report zero indefinitely and are exempt by design, so treat it as a signal rather than a gate. |
| Transports | Hints — internal, usb, nfc, ble, hybrid — describing how an authenticator might be reached. They do not identify a specific physical device. |
Design overview
Six endpoints. Two are reachable without a token, because a sign-in has none yet; the other four require one, because a passkey may only be added to an account by someone already holding that account's session.
| Method | Path | Purpose | Access |
|---|---|---|---|
POST | /api/passkeys/assertion/options | Begin sign-in | public |
POST | /api/passkeys/assertion | Verify, issue tokens | public |
POST | /api/passkeys/registration/options | Begin adding a passkey | authenticated |
POST | /api/passkeys/registration | Verify, store credential | authenticated |
GET | /api/passkeys | List the caller's passkeys | authenticated |
DELETE | /api/passkeys/{id} | Revoke one | authenticated |
The sign-in path stays usernameless throughout:
Sign-in page
↓
POST /assertion/options (creates ceremony state, returns request options)
↓
navigator.credentials.get()
↓
POST /assertion (assertionId + credential)
↓
WebAuthn verification (Spring Security + WebAuthn4J)
↓
Resolve account from the user handle
↓
Apply account-authorization rules
↓
Issue normal JWT/access tokens
Why not http.webAuthn()
Spring Security ships a DSL that wires all of this up in about six lines. It holds the in-flight ceremony in the HttpSession and finishes by placing a WebAuthnAuthentication into a session-backed security context. If your application uses sessions, use it; it is the shortest path.
In a stateless JWT API it works against you. You end up adding a session, then an endpoint to exchange that session for a token, then another to clear the security context so usernameless sign-in works at all.
WebAuthnRelyingPartyOperations is the interface the DSL itself is built on. It builds the options and delegates verification to webauthn4j, with no opinion about how the result becomes a session. Using it directly costs roughly 200 lines of service and controller code, and leaves your filter chain untouched. You are skipping the plumbing, not the cryptography.
Files
Step 1: Add the dependency
spring-boot-starter-security does not include WebAuthn. It ships as a separate artifact because it brings webauthn4j with it. Boot's dependency management pins the version, so you do not supply one.
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-webauthn</artifactId>
</dependency>
Step 2: Store credentials
Spring Security requires an implementation of UserCredentialRepository. It ships a JDBC one with its own schema; a JPA entity keeps passkeys in the same transaction and migration story as the rest of your data.
@Entity
@Table(name = "webauthn_credential",
indexes = @Index(name = "idx_webauthn_credential_user", columnList = "user_id"))
public class WebAuthnCredential {
/** The authenticator's own id, base64url. Natural key: an assertion arrives holding just this. */
@Id
@Column(name = "credential_id", length = 2048, updatable = false)
private String credentialId;
@Column(name = "user_id", nullable = false, updatable = false)
private UUID userId;
@Column(nullable = false, length = 120)
private String label;
@Column(name = "credential_type", length = 32)
private String credentialType;
@Column(name = "public_key_cose", nullable = false)
private byte[] publicKeyCose;
@Column(name = "signature_count", nullable = false)
private long signatureCount;
@Column(name = "uv_initialized", nullable = false)
private boolean uvInitialized;
@Column(name = "backup_eligible", nullable = false)
private boolean backupEligible;
@Column(name = "backup_state", nullable = false)
private boolean backupState;
/** Comma-separated: internal, hybrid, usb, nfc, ble. */
@Column(length = 255)
private String transports;
@Column(name = "attestation_object")
private byte[] attestationObject;
@Column(name = "attestation_client_data_json")
private byte[] attestationClientDataJson;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "last_used_at", nullable = false)
private Instant lastUsedAt;
// protected no-arg constructor for JPA, all-args constructor, getters,
// setters for label / signatureCount / backupState / lastUsedAt
}
The Spring Data repository scopes every request-reachable query by user id. A credential id is not a secret, since it travels to the browser in the allow-list of every assertion, so a lookup by id alone would let any signed-in user rename or delete another user's passkey.
public interface WebAuthnCredentialRepository extends JpaRepository<WebAuthnCredential, String> {
List<WebAuthnCredential> findByUserIdOrderByCreatedAtAsc(UUID userId);
Optional<WebAuthnCredential> findByCredentialIdAndUserId(String credentialId, UUID userId);
long countByUserId(UUID userId);
long deleteByCredentialIdAndUserId(String credentialId, UUID userId);
}
Then the adapter onto Spring's interface. Note that save is called twice for different reasons: once at registration with a new record, and again after every successful assertion with an updated signature count. It is therefore an explicit upsert rather than a blind save.
@Repository
public class JpaUserCredentialRepository implements UserCredentialRepository {
private final WebAuthnCredentialRepository credentials;
public JpaUserCredentialRepository(WebAuthnCredentialRepository credentials) {
this.credentials = credentials;
}
@Override
@Transactional
public void save(CredentialRecord record) {
String id = record.getCredentialId().toBase64UrlString();
Optional<WebAuthnCredential> existing = credentials.findById(id);
if (existing.isPresent()) {
// Update only the fields that change per assertion. Writing the whole record
// would undo a rename, and a blind save would resurrect a credential the
// user deleted between the two calls.
WebAuthnCredential row = existing.get();
row.setSignatureCount(record.getSignatureCount());
row.setLastUsedAt(record.getLastUsed());
row.setBackupState(record.isBackupState());
credentials.save(row);
return;
}
credentials.save(toEntity(record));
}
@Override
@Transactional(readOnly = true)
public CredentialRecord findByCredentialId(Bytes credentialId) {
return credentials.findById(credentialId.toBase64UrlString())
.map(JpaUserCredentialRepository::toRecord)
.orElse(null);
}
@Override
@Transactional(readOnly = true)
public List<CredentialRecord> findByUserId(Bytes userId) {
return UserHandles.toUserId(userId)
.map(credentials::findByUserIdOrderByCreatedAtAsc)
.orElseGet(List::of)
.stream()
.map(JpaUserCredentialRepository::toRecord)
.toList();
}
@Override
@Transactional
public void delete(Bytes credentialId) {
credentials.deleteById(credentialId.toBase64UrlString());
}
private static WebAuthnCredential toEntity(CredentialRecord record) {
UUID userId = UserHandles.toUserId(record.getUserEntityUserId())
.orElseThrow(() -> new IllegalArgumentException("Unknown user handle"));
return new WebAuthnCredential(
record.getCredentialId().toBase64UrlString(),
userId,
record.getLabel(),
record.getCredentialType() == null ? null : record.getCredentialType().getValue(),
record.getPublicKey().getBytes(),
record.getSignatureCount(),
record.isUvInitialized(),
record.isBackupEligible(),
record.isBackupState(),
encodeTransports(record.getTransports()),
bytesOrNull(record.getAttestationObject()),
bytesOrNull(record.getAttestationClientDataJSON()),
record.getCreated(),
record.getLastUsed());
}
static CredentialRecord toRecord(WebAuthnCredential row) {
return ImmutableCredentialRecord.builder()
.credentialId(Bytes.fromBase64(row.getCredentialId()))
.userEntityUserId(UserHandles.of(row.getUserId()))
.label(row.getLabel())
.credentialType(row.getCredentialType() == null
? null : PublicKeyCredentialType.valueOf(row.getCredentialType()))
.publicKey(new ImmutablePublicKeyCose(row.getPublicKeyCose()))
.signatureCount(row.getSignatureCount())
.uvInitialized(row.isUvInitialized())
.backupEligible(row.isBackupEligible())
.backupState(row.isBackupState())
.transports(decodeTransports(row.getTransports()))
.attestationObject(row.getAttestationObject() == null
? null : new Bytes(row.getAttestationObject()))
.attestationClientDataJSON(row.getAttestationClientDataJson() == null
? null : new Bytes(row.getAttestationClientDataJson()))
.created(row.getCreatedAt())
.lastUsed(row.getLastUsedAt())
.build();
}
private static byte[] bytesOrNull(Bytes bytes) {
return bytes == null ? null : bytes.getBytes();
}
private static String encodeTransports(Set<AuthenticatorTransport> transports) {
if (transports == null || transports.isEmpty()) return null;
return transports.stream().map(AuthenticatorTransport::getValue)
.sorted().collect(Collectors.joining(","));
}
private static Set<AuthenticatorTransport> decodeTransports(String encoded) {
if (encoded == null || encoded.isBlank()) return Set.of();
return Arrays.stream(encoded.split(",")).map(String::trim).filter(v -> !v.isEmpty())
// valueOf, not a constant lookup: returns a new instance for a transport
// this Spring Security version has no constant for, rather than throwing
// on one the browser has started sending.
.map(AuthenticatorTransport::valueOf)
.collect(Collectors.toUnmodifiableSet());
}
}
Step 3: Derive the user handle
Spring's other required repository is a directory of WebAuthn users. Its JDBC implementation stores a random 32-byte handle per user in a user_entities table.
You probably do not need that table. If your user id is a UUID it is already sixteen opaque bytes, so you can derive the handle instead of storing it. The mapping becomes arithmetic: a passkey can never point at a user who does not exist, and deleting a user cannot strand a directory row.
public final class UserHandles {
private static final int LENGTH = 16;
private UserHandles() {}
public static Bytes of(UUID userId) {
ByteBuffer buffer = ByteBuffer.allocate(LENGTH);
buffer.putLong(userId.getMostSignificantBits());
buffer.putLong(userId.getLeastSignificantBits());
return new Bytes(buffer.array());
}
/** Empty rather than thrown: a handle that is not ours means "no such user", not a fault. */
public static Optional<UUID> toUserId(Bytes handle) {
byte[] bytes = handle.getBytes();
if (bytes.length != LENGTH) return Optional.empty();
ByteBuffer buffer = ByteBuffer.wrap(bytes);
return Optional.of(new UUID(buffer.getLong(), buffer.getLong()));
}
}
A UUID is safe to expose this way: the specification requires only that a handle carry no personal information, and it is already your JWT's subject claim. The user directory then reads through to the table you already have.
@Repository
public class AppUserEntityRepository implements PublicKeyCredentialUserEntityRepository {
private final AppUserRepository users;
public AppUserEntityRepository(AppUserRepository users) {
this.users = users;
}
@Override
@Transactional(readOnly = true)
public PublicKeyCredentialUserEntity findById(Bytes id) {
return UserHandles.toUserId(id).flatMap(users::findById).map(this::toEntity).orElse(null);
}
@Override
@Transactional(readOnly = true)
public PublicKeyCredentialUserEntity findByUsername(String username) {
if (username == null || username.isBlank()) return null;
return users.findByEmail(username.trim().toLowerCase(Locale.ROOT))
.map(this::toEntity).orElse(null);
}
/**
* Refuses deliberately. The only path that reaches this is the relying party trying to
* invent a user for a username it did not recognise, and creating accounts is not
* something a passkey registration endpoint should be able to do.
*/
@Override
public void save(PublicKeyCredentialUserEntity userEntity) {
throw new UnsupportedOperationException("Users are provisioned through sign-up");
}
@Override
public void delete(Bytes id) {
throw new UnsupportedOperationException("Users are deleted through the user service");
}
private PublicKeyCredentialUserEntity toEntity(AppUser user) {
String displayName = user.getDisplayName();
return ImmutablePublicKeyCredentialUserEntity.builder()
.id(UserHandles.of(user.getId()))
.name(user.getEmail()) // what the browser matches a stored passkey against
// What the OS prompt shows. An empty display name renders as a blank row
// in the passkey picker, indistinguishable from a broken credential.
.displayName(displayName == null || displayName.isBlank()
? user.getEmail() : displayName)
.build();
}
}
Step 4: Hold the challenge
This is the piece the DSL was providing through HttpSession. Your server must remember the random value it issued, or the signature that comes back proves nothing.
Instead of a session, mint a random handle, return it alongside the options, and have the browser echo it back. Three properties make that safe:
- Single use. A retrieval removes the entry, so a signed challenge cannot be presented twice. This holds even when the first attempt failed.
- Short lived. Expiry is enforced on read, not by a background sweeper.
- Bounded. The handle is attacker-controlled, since the endpoint that mints it needs no token. An unbounded map here would itself be a denial-of-service vector.
@Component
public class PasskeyChallengeStore {
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
private final Map<String, Pending> pending;
private final Duration ttl;
public PasskeyChallengeStore(
@Value("${app.passkeys.challenge-ttl:5m}") Duration ttl,
@Value("${app.passkeys.max-pending-challenges:10000}") int maxPending) {
this.ttl = ttl;
this.pending = Collections.synchronizedMap(new LinkedHashMap<>(256, 0.75f, true) {
@Override protected boolean removeEldestEntry(Map.Entry<String, Pending> eldest) {
return size() > maxPending;
}
});
}
/** @param userId the user the ceremony belongs to, or null for a sign-in. */
public String store(Object options, UUID userId) {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
String handle = ENCODER.encodeToString(bytes);
pending.put(handle, new Pending(options, userId, Instant.now().plus(ttl)));
return handle;
}
/**
* Removal happens before the freshness and type checks, so a handle presented with the
* wrong half of the wrong ceremony is spent either way. Anything else would let a
* caller probe for live handles.
*/
public <T> Optional<Consumed<T>> consume(String handle, Class<T> optionsType) {
if (handle == null || handle.isBlank()) return Optional.empty();
Pending entry = pending.remove(handle);
if (entry == null
|| entry.expiresAt().isBefore(Instant.now())
|| !optionsType.isInstance(entry.options())) {
return Optional.empty();
}
return Optional.of(new Consumed<>(optionsType.cast(entry.options()), entry.userId()));
}
private record Pending(Object options, UUID userId, Instant expiresAt) {}
public record Consumed<T>(T options, UUID userId) {}
}
State is per instance. Behind more than one replica a ceremony must reach the instance that started it, so replace the map with a shared store if that is not acceptable.
Step 5: Configure the relying party
@Configuration
public class PasskeyConfig {
@Bean
WebAuthnRelyingPartyOperations relyingPartyOperations(
PublicKeyCredentialUserEntityRepository userEntities,
UserCredentialRepository userCredentials,
@Value("${app.passkeys.rp-id}") String rpId,
@Value("${app.passkeys.rp-name}") String rpName,
@Value("${app.passkeys.allowed-origins}") Set<String> allowedOrigins) {
PublicKeyCredentialRpEntity rp = PublicKeyCredentialRpEntity.builder()
.id(rpId).name(rpName).build();
Webauthn4JRelyingPartyOperations operations = new Webauthn4JRelyingPartyOperations(
userEntities, userCredentials, rp, allowedOrigins);
operations.setCustomizeCreationOptions(options -> options
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
// Discoverable credential: the browser can offer the account before
// anything is typed. Usernameless sign-in depends on this.
.residentKey(ResidentKeyRequirement.REQUIRED)
// REQUIRED, not Spring's default of PREFERRED.
.userVerification(UserVerificationRequirement.REQUIRED)
.build()));
// Enforced independently of registration: webauthn4j only checks the UV flag when
// the *request* options say REQUIRED.
operations.setCustomizeRequestOptions(options -> options
.userVerification(UserVerificationRequirement.REQUIRED));
return operations;
}
}
Validate rp-id and allowed-origins at startup. Both are facts about the browser's environment that nothing in the JVM can verify, so the alternative to failing fast is failing in every user's browser with an error your server never sees. An empty origin list is the more serious of the two: the relying party would then verify a ceremony from any origin.
Step 6: Serialise WebAuthn types
The WebAuthn API types carry their serialisation in Jackson mix-ins rather than annotations (Bytes is base64url, PublicKeyCredential is polymorphic in its response), so they need the module registered to survive a round trip.
Register it on a private mapper, never your application's. Boot auto-configures the application mapper behind @ConditionalOnMissingBean, so publishing a second JsonMapper bean does not sit alongside it. It replaces it, and every controller in your application begins serialising through a mapper configured for passkeys.
@Component
public class WebAuthnJson {
// Boot 4 / Jackson 3. On Boot 3: com.fasterxml.jackson.databind.ObjectMapper
// with WebauthnJackson2Module.
private final JsonMapper mapper = JsonMapper.builder()
.addModule(new WebauthnJacksonModule())
.build();
/** Options as a tree, so the ordinary mapper can nest them without knowing WebAuthn. */
public JsonNode toTree(Object options) {
return mapper.valueToTree(options);
}
public PublicKeyCredential<AuthenticatorAttestationResponse> readAttestation(JsonNode node) {
return mapper.treeToValue(node, new TypeReference<>() {});
}
public PublicKeyCredential<AuthenticatorAssertionResponse> readAssertion(JsonNode node) {
return mapper.treeToValue(node, new TypeReference<>() {});
}
}
Your request and response DTOs carry the WebAuthn halves as JsonNode for the same reason: the ordinary mapper moves them through untouched while this one owns their encoding.
public record RegistrationOptionsResponse(String registrationId, JsonNode publicKeyCredentialCreationOptions) {}
public record RegistrationRequest(@NotBlank String registrationId, @Size(max = 120) String label, JsonNode credential) {}
public record AssertionOptionsResponse(String assertionId, JsonNode publicKeyCredentialRequestOptions) {}
public record AssertionRequest(@NotBlank String assertionId, JsonNode credential) {}
public record PasskeySummary(String id, String label, String transports,
boolean backedUp, Instant createdAt, Instant lastUsedAt) {}
Step 7: The service
Two checks here exist because the relying party does not make them: it will finish a registration ceremony for whoever presents the options, and it identifies a signer without any opinion on whether that account should be admitted.
@Service
public class PasskeyService {
private static final Logger log = LoggerFactory.getLogger(PasskeyService.class);
private static final int MAX_CREDENTIALS_PER_USER = 20;
private final WebAuthnRelyingPartyOperations relyingParty;
private final WebAuthnCredentialRepository credentials;
private final PasskeyChallengeStore challenges;
private final WebAuthnJson json;
private final AppUserRepository users;
private final JwtIssuer jwtIssuer; // whatever your app already uses to mint a session
// constructor omitted
// Registration
public RegistrationOptionsResponse startRegistration(UUID userId, String email) {
requireRoomForAnotherCredential(userId);
PublicKeyCredentialCreationOptions options =
relyingParty.createPublicKeyCredentialCreationOptions(
new ImmutablePublicKeyCredentialCreationOptionsRequest(usernameOf(email)));
return new RegistrationOptionsResponse(
challenges.store(options, userId), json.toTree(options));
}
@Transactional
public PasskeySummary finishRegistration(UUID userId, String registrationId,
String label, JsonNode credential) {
PublicKeyCredentialCreationOptions options = challenges
.consume(registrationId, PublicKeyCredentialCreationOptions.class)
// The check the relying party cannot make: is this the user who started it?
.filter(consumed -> userId.equals(consumed.userId()))
.map(PasskeyChallengeStore.Consumed::options)
.orElseThrow(() -> new PasskeyException(
"This registration has expired or already been used."));
// Re-checked after the ceremony: the round trip goes through a device the user may
// take their time with, and nothing stops several running at once.
requireRoomForAnotherCredential(userId);
CredentialRecord record;
try {
record = relyingParty.registerCredential(new ImmutableRelyingPartyRegistrationRequest(
options,
new RelyingPartyPublicKey(json.readAttestation(credential), cleanLabel(label))));
} catch (RuntimeException ex) {
// webauthn4j reports every verification failure as an unchecked exception, and its
// messages describe cryptography rather than anything the user did.
log.warn("Passkey registration failed for user {}", userId, ex);
throw new PasskeyException("That passkey could not be registered.");
}
return credentials
.findByCredentialIdAndUserId(record.getCredentialId().toBase64UrlString(), userId)
.map(PasskeyService::toSummary)
.orElseThrow(() -> new IllegalStateException("Credential not readable back"));
}
// Authentication
public AssertionOptionsResponse startAuthentication() {
// A null authentication yields an empty allowCredentials list, which is what makes
// this usernameless. The response is identical whether the caller has an account,
// a hundred, or none.
PublicKeyCredentialRequestOptions options = relyingParty.createCredentialRequestOptions(
new ImmutablePublicKeyCredentialRequestOptionsRequest(null));
return new AssertionOptionsResponse(
challenges.store(options, null), json.toTree(options));
}
@Transactional
public Tokens finishAuthentication(String assertionId, JsonNode credential) {
PublicKeyCredentialRequestOptions options = challenges
.consume(assertionId, PublicKeyCredentialRequestOptions.class)
.map(PasskeyChallengeStore.Consumed::options)
.orElseThrow(() -> new PasskeyUnauthorized(
"This sign-in attempt has expired. Please try again."));
PublicKeyCredentialUserEntity userEntity;
try {
userEntity = relyingParty.authenticate(new RelyingPartyAuthenticationRequest(
options, json.readAssertion(credential)));
} catch (RuntimeException ex) {
log.warn("Passkey assertion rejected", ex);
throw new PasskeyUnauthorized("Authentication failed.");
}
UUID userId = UserHandles.toUserId(userEntity.getId())
.orElseThrow(() -> new PasskeyUnauthorized("Authentication failed."));
AppUser user = users.findById(userId)
.orElseThrow(() -> new PasskeyUnauthorized("Authentication failed."));
// The signature proves possession of a credential, not that this account may be
// admitted. Without this, a passkey is the one credential a disabled user can
// still sign in with.
if (!user.isEnabled()) throw new PasskeyUnauthorized("Authentication failed.");
return jwtIssuer.issue(user);
}
// Management
@Transactional(readOnly = true)
public List<PasskeySummary> list(UUID userId) {
return credentials.findByUserIdOrderByCreatedAtAsc(userId).stream()
.map(PasskeyService::toSummary).toList();
}
/**
* Scoped to the owner, so another user's credential id reads as absent rather than
* forbidden. A credential id is not secret; distinguishing the two would confirm that
* a given passkey exists.
*/
@Transactional
public void delete(UUID userId, String credentialId) {
if (credentials.deleteByCredentialIdAndUserId(credentialId, userId) == 0) {
throw new PasskeyException("No such passkey.");
}
}
// Internals
/**
* The relying party reads the WebAuthn username off Authentication#getName(). Your
* security context holds a Jwt whose name is the user's UUID, so passing it through
* would register every passkey under a name no human recognises. This token carries
* the email instead; it never reaches the security context and authorises nothing.
*/
private static Authentication usernameOf(String email) {
return UsernamePasswordAuthenticationToken.authenticated(email, null, List.of());
}
private void requireRoomForAnotherCredential(UUID userId) {
if (credentials.countByUserId(userId) >= MAX_CREDENTIALS_PER_USER) {
throw new PasskeyException("Remove a passkey before adding another.");
}
}
private static String cleanLabel(String label) {
if (label == null || label.isBlank()) return "Passkey";
String trimmed = label.strip();
return trimmed.length() > 120 ? trimmed.substring(0, 120) : trimmed;
}
private static PasskeySummary toSummary(WebAuthnCredential row) {
return new PasskeySummary(row.getCredentialId(), row.getLabel(), row.getTransports(),
row.isBackupState(), row.getCreatedAt(), row.getLastUsedAt());
}
}
Step 8: Controller and security rules
@RestController
@RequestMapping("/api/passkeys")
public class PasskeyController {
private final PasskeyService passkeys;
// constructor omitted
@GetMapping
public List<PasskeySummary> list(@AuthenticationPrincipal Jwt jwt) {
return passkeys.list(UUID.fromString(jwt.getSubject()));
}
@PostMapping("/registration/options")
public ResponseEntity<RegistrationOptionsResponse> registrationOptions(
@AuthenticationPrincipal Jwt jwt) {
return noStore(passkeys.startRegistration(
UUID.fromString(jwt.getSubject()), jwt.getClaimAsString("email")));
}
@PostMapping("/registration")
public ResponseEntity<PasskeySummary> register(
@AuthenticationPrincipal Jwt jwt, @Valid @RequestBody RegistrationRequest request) {
PasskeySummary created = passkeys.finishRegistration(
UUID.fromString(jwt.getSubject()),
request.registrationId(), request.label(), request.credential());
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@DeleteMapping("/{credentialId}")
public ResponseEntity<Void> delete(
@AuthenticationPrincipal Jwt jwt, @PathVariable String credentialId) {
passkeys.delete(UUID.fromString(jwt.getSubject()), credentialId);
return ResponseEntity.noContent().build();
}
@PostMapping("/assertion/options")
public ResponseEntity<AssertionOptionsResponse> assertionOptions() {
return noStore(passkeys.startAuthentication());
}
/**
* No Origin check of its own: the origin the browser saw is inside the client data the
* authenticator signed, and the relying party checks it against the allow-list. That is
* stronger than a header, which the caller controls.
*/
@PostMapping("/assertion")
public ResponseEntity<Tokens> assertion(@Valid @RequestBody AssertionRequest request) {
Tokens tokens = passkeys.finishAuthentication(
request.assertionId(), request.credential());
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(tokens);
}
/** Options embed a live challenge, so they must not be held by a browser or a proxy. */
private static <T> ResponseEntity<T> noStore(T body) {
return ResponseEntity.ok().header(HttpHeaders.CACHE_CONTROL, "no-store").body(body);
}
}
.authorizeHttpRequests(requests -> requests
// Open, because a sign-in has no token yet. What authenticates these is the
// ceremony: a signature over a server-held, single-use challenge, made by a key
// already registered here, from an origin on the allow-list.
.requestMatchers(HttpMethod.POST,
"/api/passkeys/assertion",
"/api/passkeys/assertion/options").permitAll()
// Everything else under /api/passkeys requires a token.
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll())
Step 9: Configuration
app:
passkeys:
# The domain every credential is bound to, permanently. Must be the origin's
# registrable domain or a parent of it. Use "localhost" in development, since
# browsers treat it as a secure context and WebAuthn works there over plain HTTP.
rp-id: ${PASSKEY_RP_ID:localhost}
# Shown in the Touch ID / Windows Hello prompt.
rp-name: Example
# The front end's origin, NOT the API's, matched exactly: scheme, host and port.
# With a dev server proxying /api, the browser's origin is the dev server's and
# the API's port never appears.
allowed-origins: ${APP_ALLOWED_ORIGINS:http://localhost:5173}
challenge-ttl: 5m
max-pending-challenges: 10000
| Property | Development | Production |
|---|---|---|
rp-id | localhost | app.example.com |
allowed-origins | http://localhost:5173 | https://app.example.com |
challenge-ttl | 5m, matching the timeout inside the options themselves | |
Step 10: Client: encoding
Everything crossing this boundary is bytes. navigator.credentials deals in ArrayBuffer, your server in base64url strings — and since March 2025, PublicKeyCredential can do that conversion itself.
/** False on http:// origins other than localhost. */
export function isPasskeySupported(): boolean {
return typeof window !== 'undefined'
&& typeof window.PublicKeyCredential !== 'undefined'
&& typeof navigator.credentials?.create === 'function';
}
export class PasskeyCancelledError extends Error {
constructor() { super('Passkey prompt was dismissed.'); this.name = 'PasskeyCancelledError'; }
}
PublicKeyCredential.parseCreationOptionsFromJSON() and parseRequestOptionsFromJSON() take the server's JSON as-is, including nested fields like user.id and every excludeCredentials[].id, and hand back the ArrayBuffer-bearing options object navigator.credentials expects. The credential's own toJSON() does the same in reverse. Baseline since March 2025 — Chrome and Edge 129+, Firefox 119+, Safari 18.4+; Android WebView still lacks it at this writing — and between the three of them, essentially everything below stops being necessary.
Feature-detect before relying on it, and keep this as a fallback for a browser without it:
// base64url, not base64: '-' and '_' for '+' and '/', no '=' padding.
// Java's Base64.getUrlDecoder() rejects the standard alphabet.
function toBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
// Chunked rather than String.fromCharCode(...bytes): an attestation object runs to
// several kilobytes, and spreading that many arguments overflows the call stack.
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromBase64Url(value: string): ArrayBuffer {
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, '='));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
interface ServerDescriptor { type: string; id: string; transports?: string[] }
function toDescriptors(list: ServerDescriptor[] | undefined): PublicKeyCredentialDescriptor[] {
return (list ?? []).map(d => ({
type: 'public-key',
id: fromBase64Url(d.id),
transports: d.transports as AuthenticatorTransport[] | undefined,
}));
}
Step 11: Client: register
export async function registerPasskey(label: string): Promise<Passkey> {
const { registrationId, publicKeyCredentialCreationOptions: options } =
await apiJson('/api/passkeys/registration/options', { method: 'POST' });
const publicKey = 'parseCreationOptionsFromJSON' in PublicKeyCredential
? PublicKeyCredential.parseCreationOptionsFromJSON(options)
: {
challenge: fromBase64Url(options.challenge),
rp: options.rp,
user: {
id: fromBase64Url(options.user.id),
name: options.user.name,
displayName: options.user.displayName,
},
pubKeyCredParams: options.pubKeyCredParams,
timeout: options.timeout,
excludeCredentials: toDescriptors(options.excludeCredentials),
authenticatorSelection: options.authenticatorSelection,
attestation: options.attestation,
extensions: options.extensions,
};
const credential = (await navigator.credentials.create({ publicKey }).catch(() => {
// The spec reports "cancelled", "already registered" and "authenticator refused"
// identically, so a page cannot enumerate a user's devices.
throw new PasskeyCancelledError();
})) as PublicKeyCredential | null;
if (!credential) throw new PasskeyCancelledError();
const payload = 'toJSON' in credential
? credential.toJSON()
: (() => {
const response = credential.response as AuthenticatorAttestationResponse;
return {
id: credential.id,
rawId: toBase64Url(credential.rawId),
type: credential.type,
clientExtensionResults: credential.getClientExtensionResults(),
response: {
clientDataJSON: toBase64Url(response.clientDataJSON),
attestationObject: toBase64Url(response.attestationObject),
transports: response.getTransports?.() ?? [],
},
};
})();
return apiJson('/api/passkeys/registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ registrationId, label, credential: payload }),
});
}
Step 12: Client: sign in
No identifier is typed and none is sent. This works because of residentKey: REQUIRED. The browser asks its own credential store which passkeys exist for your domain and offers them.
export async function signInWithPasskey(): Promise<TokenResponse> {
// Plain fetch, not your authenticated client: there is no session yet, and an
// auth-aware wrapper would try to refresh one and report its absence as a dead session.
const optionsResponse = await fetch('/api/passkeys/assertion/options', {
method: 'POST',
credentials: 'include',
headers: { Accept: 'application/json' },
});
if (!optionsResponse.ok) throw new Error('Could not start a passkey sign-in.');
const { assertionId, publicKeyCredentialRequestOptions: options } =
await optionsResponse.json();
const publicKey = 'parseRequestOptionsFromJSON' in PublicKeyCredential
? PublicKeyCredential.parseRequestOptionsFromJSON(options)
: {
challenge: fromBase64Url(options.challenge),
rpId: options.rpId,
timeout: options.timeout,
allowCredentials: toDescriptors(options.allowCredentials), // empty
userVerification: options.userVerification,
extensions: options.extensions,
};
const credential = (await navigator.credentials.get({ publicKey })
.catch(() => { throw new PasskeyCancelledError(); })) as PublicKeyCredential | null;
if (!credential) throw new PasskeyCancelledError();
const payload = 'toJSON' in credential
? credential.toJSON()
: (() => {
const response = credential.response as AuthenticatorAssertionResponse;
return {
id: credential.id,
rawId: toBase64Url(credential.rawId),
type: credential.type,
clientExtensionResults: credential.getClientExtensionResults(),
response: {
clientDataJSON: toBase64Url(response.clientDataJSON),
authenticatorData: toBase64Url(response.authenticatorData),
signature: toBase64Url(response.signature),
userHandle: response.userHandle ? toBase64Url(response.userHandle) : null,
},
};
})();
const verified = await fetch('/api/passkeys/assertion', {
method: 'POST',
credentials: 'include',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ assertionId, credential: payload }),
});
if (!verified.ok) throw new Error('That passkey could not be used to sign in.');
// The same token payload every other sign-in path returns, so the rest of the app
// never learns there was a second way in.
return verified.json();
}
Two notes for the UI. Gate the button on isPasskeySupported() rather than on a platform authenticator existing, since a security key works on a machine with no fingerprint reader. And treat PasskeyCancelledError as a non-event: dismissing the prompt is normal, and showing a failure message for it teaches users the feature is unreliable.
Testing without a fingerprint
Chrome has a virtual authenticator built in. DevTools → ⋮ → More tools → WebAuthn → enable the virtual authenticator environment → Add:
- Protocol ctap2, transport internal
- Supports resident keys: on, required by
residentKey: REQUIRED - Supports user verification: on, required by
userVerification: REQUIRED - Automatically set user verification: on
Both toggles are mandatory given the configuration above. An authenticator without them declines silently, and the browser reports that identically to a cancellation, so a misconfigured test rig looks exactly like broken application code.
Use http://localhost, not 127.0.0.1. They are different origins; localhost is a secure context by special dispensation, while 127.0.0.1 fails the origin check inside the signed client data.
What you can cover in ordinary tests, without a browser: challenge single-use, expiry, and eviction under pressure; user-handle round trip and rejection of foreign handles; credential mapping field by field; and the JSON payload shape in both directions.
Deleting a passkey does not necessarily delete it from the authenticator
DELETE /api/passkeys/{id} removes the row from your database. It does not reach into the user's platform authenticator or password manager — iCloud Keychain, Google Password Manager, a hardware key's own storage — which keeps its own independent copy of the discoverable credential. The result: a passkey you have already deleted server-side can keep appearing in the browser's account picker, and selecting it fails, because your server no longer recognizes the credential ID it sends back.
WebAuthn Level 3 adds two static methods on PublicKeyCredential for exactly this problem, informally called the WebAuthn Signal API. They let the relying party tell the platform which credentials it still considers valid, so the authenticator can prune the rest from its own store.
/**
* Called after the caller's own passkey list changes — a delete, or a fresh
* fetch of GET /api/passkeys. The list must be the *complete* set the server
* still accepts for this user: anything left out is treated as "no longer
* valid" and may be pruned from the authenticator's own store, so a partial
* or stale list here can silently delete a passkey the user never removed.
*/
async function syncAcceptedCredentials(rpId: string, userId: string, credentialIds: string[]) {
if (!('signalAllAcceptedCredentials' in PublicKeyCredential)) return; // feature-detect
await PublicKeyCredential.signalAllAcceptedCredentials({
rpId,
userId,
allAcceptedCredentialIds: credentialIds,
});
}
Call it from wherever your client already holds the caller's full passkey list, immediately after a successful delete (or after loading GET /api/passkeys) — userId and rpId here are the same base64url user handle and relying-party id the registration and authentication ceremonies already use.
The second method covers the opposite direction: a stale credential presented at sign-in, before the user is authenticated.
/**
* Called after a sign-in attempt fails because the server does not recognize
* the credential the authenticator offered — exactly the case a deleted
* passkey produces. Safe to call unauthenticated: it names one specific
* credential rather than asserting anything about the account.
*/
async function signalStaleCredential(rpId: string, credentialId: string) {
if (!('signalUnknownCredential' in PublicKeyCredential)) return;
await PublicKeyCredential.signalUnknownCredential({ rpId, credentialId });
}
A third, related method, signalCurrentUserDetails({ rpId, userId, name, displayName }), keeps the account picker's username and display name in sync after a profile update, so it is worth wiring up alongside these two rather than as an afterthought.
Common pitfalls
-
rp-id is write-once
There is no migration. Change it and every passkey every user has registered silently stops being offered by their authenticator; the only recovery is to sign in another way and register again. Use the exact host, or its registrable parent to cover future subdomains.
-
allowed-origins is the front end's origin
Not the API's. Scheme, host and port, matched exactly. With an SPA on a dev server that proxies
/api, the browser's origin is the dev server's. Validate this andrp-idat startup: an empty origin list makes the relying party accept a ceremony from any origin. -
Registration is not sign-up
Keep it behind your authenticated chain. Left open, the endpoint becomes an account-creation path and removes the enumeration safety of your sign-in form.
-
A failed attempt must still consume the challenge
Consume before verifying. If a bad signature leaves the challenge live, an attacker gets unlimited attempts against one challenge and the replay protection is lost with no test detecting it.
-
Verifying a signature is not authorising a user
The assertion proves possession of a credential. Whether that account may be admitted is a separate question, so check enabled, lockout, and anything else your application checks. Omit it and a passkey becomes the one credential a disabled user can still sign in with.
Conclusion
The result: no sessions, no adapters converting one kind of authentication into another, and a sign-in that ends in exactly the token payload the rest of your application already understands. For the user: a sign-in page with a button and no email field.
When you implement this, remember to:
- Choose
rp-iddeliberately. It is permanent, and changing it invalidates every credential your users own. - Set
userVerificationtoREQUIREDon both ceremonies if the passkey replaces a full sign-in rather than acting as a second factor. - Keep registration behind authentication, so adding a passkey remains something only the account holder can do.
- Make challenges single-use, consumed before verification, and bounded in number.
- Re-check the account after the signature verifies. Cryptography answers who, and your application answers whether they may.
- Test the JSON payload shape in both directions. It is the one boundary where a mistake is silent.
Passkeys are not an all-or-nothing migration. Keep your existing sign-in, add this beside it, and let users opt in from a settings screen. Every passkey registered is one account that can no longer be phished.
References
- W3C — Web Authentication: An API for accessing Public Key Credentials, Level 3
- W3C — Secure Contexts
- Spring Security — Passkeys / WebAuthn reference documentation
- FIDO Alliance — Passkeys FAQ
- Passkeys UX guidance — passkeys.dev