1
0
Files
spring-auth-demo/docs/passkeys/09-persistence.md
Ankur Mhatre f6dd692177 Add passkeys project: WebAuthn ceremonies, a software authenticator and the one-time-token fallback
Fourth Maven project in the repository. Registration and authentication run end to
end with no browser and no hardware key: VirtualAuthenticator emits real CBOR
attestation objects and real ES256 assertion signatures, and tools/PasskeyCeremony.java
drives the live HTTP endpoints with them.

Profiles cover userVerification REQUIRED, DIRECT attestation, a disallowed origin and
JDBC persistence. Eleven doc chapters and twelve captured transcripts under docs/passkeys
and docs/output/pk-*.txt, all regenerated by passkeys/scripts/run-all.sh.
2026-08-25 23:00:27 +05:30

4.7 KiB

← 08 — The one-time-token fallback · index · next: 10 — The signature counter

Persistence

Two repositories, and the default for both is a HashMap.

interface default JDBC implementation
PublicKeyCredentialUserEntityRepository MapPublicKeyCredentialUserEntityRepository JdbcPublicKeyCredentialUserEntityRepository
UserCredentialRepository MapUserCredentialRepository JdbcUserCredentialRepository

WebAuthnConfigurer.configure constructs the map-backed ones if no beans exist, without logging anything. In a passwordless application that is a trap with teeth: restart the process and every user's only credential is gone, and by design they have no password to fall back on.

Switching to JDBC

@Bean
PublicKeyCredentialUserEntityRepository userEntityRepository(JdbcOperations jdbc) {
    return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
}

@Bean
UserCredentialRepository userCredentialRepository(JdbcOperations jdbc) {
    return new JdbcUserCredentialRepository(jdbc);
}

Nothing creates the tables. The Javadoc points at classpath:org/springframework/security/user-credentials-schema.sql, and searching spring-security-webauthn-7.1.1.jar for it finds nothing — the two DDL files stayed in spring-security-web when the classes moved out in 7.0 (01). They are still on the classpath transitively, so this works:

spring:
  sql:
    init:
      mode: always
      schema-locations:
        - classpath:org/springframework/security/user-entities-schema.sql
        - classpath:org/springframework/security/user-credentials-schema.sql

There is a Postgres variant, user-credentials-schema-postgres.sql, which differs only in bytea versus blob. There is no Postgres variant of the user entities schema, because it does not need one.

Run it with ./scripts/run.sh jdbc; the ceremony transcript is docs/output/pk-jdbc.txt and is identical to the in-memory one, which is the point.

What a credential record holds

credential_id                 the raw id, base64url
user_entity_user_id           the random user handle, not the username
public_key                    the COSE key, as CBOR bytes
signature_count               updated on every assertion - see chapter 10
uv_initialized                whether UV was set at registration
backup_eligible / backup_state whether this is a syncable passkey, and whether it is synced
authenticator_transports      internal, hybrid, usb, nfc, ble
attestation_object            the full attestation object from registration
attestation_client_data_json  the client data from registration
created / last_used           timestamps
label                         whatever the user typed

attestation_object is nullable in the DDL and mandatory in practice. Webauthn4JRelyingPartyOperations.authenticate re-parses it on every login to recover the COSE key:

Bytes attestationObject = credentialRecord.getAttestationObject();
Assert.notNull(attestationObject, "attestationObject cannot be null");
AttestationObject wa4jAttestationObject = cborConverter.readValue(attestationObject.getBytes(), ...);

So a custom UserCredentialRepository that stores the public key but drops the attestation object — a reasonable-looking optimisation, since the public key is right there in its own column — produces credentials that register fine and can never log in. The symptom is a bare 401. That re-parse is also the mechanism behind chapter 10.

Things to decide before you ship

  • Deleting a credential. DELETE /webauthn/register/{id} exists and is guarded by CredentialRecordOwnerAuthorizationManager, so users cannot delete each other's passkeys. Make sure your UI exposes it, and make sure a user cannot delete their last one.
  • Labels. The label is user-supplied and displayed back; treat it as untrusted, and cap its length. The column is varchar(1000).
  • Session storage. The challenge lives in the HttpSession between the options call and the ceremony call. Behind a load balancer without sticky sessions, registration fails intermittently and nothing else does. Spring Session with Redis has its own open issue — spring-security#16328.
  • Backups. In a passwordless application the credential table is the account. Losing it is not a data-loss incident, it is a lockout incident for every user at once.

← 08 — The one-time-token fallback · index · next: 10 — The signature counter