1
0

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.
This commit is contained in:
2026-08-25 22:54:40 +05:30
parent e9381dc5be
commit f6dd692177
59 changed files with 3567 additions and 4 deletions

View File

@@ -0,0 +1,92 @@
[← index](README.md) · next: [02 — The minimum configuration](02-minimum-configuration.md)
# Versions, artifacts and the 7.0 split
## The dependency the tutorials forget
Passkey support arrived in Spring Security **6.4**, and at that time it lived inside
`spring-security-web` — which meant it was already on the classpath of every Spring
Boot application that used `spring-boot-starter-security`. As of Spring Security **7.0** it
does not. The classes were moved into a new artifact, `spring-security-webauthn`, which
`spring-boot-starter-security` does **not** pull in.
The move is visible in the jars:
```
$ unzip -l spring-security-web-6.5.11.jar | grep -c -i webauthn
135
$ unzip -l spring-security-web-7.1.1.jar | grep -i webauthn
10036 org/springframework/security/spring-security-webauthn.js
$ unzip -l spring-security-webauthn-7.1.1.jar | grep -c -i webauthn
139
```
The package names did **not** change — everything is still
`org.springframework.security.web.webauthn.*`. That is what makes the upgrade awkward: your
imports keep compiling against a stale local repository and fail on a clean build, and the
DSL method `http.webAuthn(..)` lives in `spring-security-config`, which is present either
way. Nothing tells you what is wrong except a `ClassNotFoundException` or a missing method.
```xml
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-webauthn</artifactId>
</dependency>
```
No version: `spring-boot-dependencies:4.1.1` manages it, at 7.1.1.
## What did not move
Two things were left behind in `spring-security-web`:
| artefact | where it lives | why it matters |
|---|---|---|
| `spring-security-webauthn.js` | `spring-security-web` | the browser-side script the default pages load |
| `user-credentials-schema.sql` | `spring-security-web` | the DDL for `JdbcUserCredentialRepository` |
| `user-entities-schema.sql` | `spring-security-web` | the DDL for `JdbcPublicKeyCredentialUserEntityRepository` |
`spring-security-webauthn` depends on `spring-security-web`, so all three are still reachable
&mdash; but a search inside the webauthn jar for the schema files the Javadoc points at comes
up empty, which is confusing the first time. See [09 &mdash; Persistence](09-persistence.md).
## The versions this module was built and run against
| | |
|---|---|
| JDK | Temurin **25.0.4.1+1** (current LTS) |
| Spring Boot | **4.1.1** |
| Spring Framework | **7.0.9** |
| Spring Security | **7.1.1** (GA 20 August 2026) |
| `spring-security-webauthn` | **7.1.1** |
| WebAuthn4J | **0.31.9.RELEASE** |
| Jackson | **3.1.5** (`tools.jackson`) |
| Tomcat | **11.0.24** |
| H2 | **2.4.240** (jdbc profile only) |
| Maven | 3.9.11 |
Two details behind that table are worth keeping:
**WebAuthn4J 0.31.9 is a Jackson 3 library.** Its POM declares
`tools.jackson.core:jackson-databind:3.2.1` and
`tools.jackson.dataformat:jackson-dataformat-cbor:3.2.1`. Spring Boot 4.1.1 manages Jackson at
3.1.5 and wins, so what actually resolves is `jackson-dataformat-cbor-3.1.5.jar`. Everything
in this module ran on that combination. If you are still on a Jackson 2 application, this is
a real constraint rather than a footnote.
**`spring-security-webauthn` 7.0.7 and 7.1.1 contain exactly the same set of classes.** A
class-by-class diff of the two jars is empty; only the pinned WebAuthn4J version moved, from
0.31.6 to 0.31.9. Nothing in this article is 7.1-specific in the way that, say,
`csrf.spa()` was 7.0-specific &mdash; it applies to the whole 7.x line, and mostly to 6.4 and
6.5 as well once you account for the artifact split.
## What Spring Boot does not do
There is no WebAuthn auto-configuration, and there are no `spring.security.webauthn.*`
properties. Grepping `spring-boot-autoconfigure-4.1.1.jar` and `spring-boot-security-4.1.1.jar`
for `webauthn` returns nothing. Every relying party setting in this module is Java
configuration, because Java configuration is the only option.
[&larr; index](README.md) &middot; next: [02 &mdash; The minimum configuration](02-minimum-configuration.md)

View File

@@ -0,0 +1,100 @@
[&larr; 01 &mdash; Versions](01-versions.md) &middot; [index](README.md) &middot; next: [03 &mdash; The two ceremonies](03-the-two-ceremonies.md)
# The minimum configuration
```java
http
.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.webAuthn((webAuthn) -> webAuthn
.rpId("localhost")
.rpName("ankurm passkeys demo")
.allowedOrigins("http://localhost:8080"));
```
Plus a `UserDetailsService` bean. Without one,
`WebAuthnConfigurer.configure` throws `IllegalStateException: Missing UserDetailsService Bean`
at startup &mdash; passkeys authenticate a *credential*, and Spring still needs somewhere to
look up the authorities that go with the username the credential resolves to.
That is the whole thing. It gives you six endpoints and a working browser flow:
| method | path | what it does |
|---|---|---|
| `POST` | `/webauthn/register/options` | issues a challenge and the creation options; requires an authenticated session |
| `POST` | `/webauthn/register` | verifies the attestation and stores a `CredentialRecord` |
| `DELETE` | `/webauthn/register/{id}` | removes a credential, guarded by `CredentialRecordOwnerAuthorizationManager` |
| `GET` | `/webauthn/register` | the built-in registration page |
| `POST` | `/webauthn/authenticate/options` | issues a challenge and the request options |
| `POST` | `/login/webauthn` | verifies the assertion and creates the session |
## rpId and allowedOrigins are two settings, not one
The **relying party id** is a domain. It is hashed into authenticator data and it scopes the
credential: a passkey created for `example.com` will be offered on `app.example.com`, because
the rpId must equal the origin's effective domain or be a registrable suffix of it.
The **allowed origin** is the exact scheme, host and port string the browser puts in client
data. `http://localhost:8080` and `http://127.0.0.1:8080` are different origins even though
they reach the same server, and `localhost` is not a registrable suffix of `127.0.0.1`. This
is the single most common way to get a flat 401 out of a configuration that looks correct.
`localhost` is special: browsers treat it as a secure context, so WebAuthn works over plain
HTTP there and nowhere else. The first deployment to a real hostname needs TLS before the
ceremony will start at all.
## The bean that silently disables the DSL
```java
private WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations(
PublicKeyCredentialUserEntityRepository userEntities, UserCredentialRepository userCredentials) {
Optional<WebAuthnRelyingPartyOperations> webauthnOperationsBean = getBeanOrNull(
WebAuthnRelyingPartyOperations.class);
String rpName = (this.rpName != null) ? this.rpName : this.rpId;
return webauthnOperationsBean
.orElseGet(() -> new Webauthn4JRelyingPartyOperations(userEntities, userCredentials,
PublicKeyCredentialRpEntity.builder().id(this.rpId).name(rpName).build(), this.allowedOrigins));
}
```
&mdash; `WebAuthnConfigurer`, Spring Security 7.1.1
If a `WebAuthnRelyingPartyOperations` bean exists, it is used **as is**. The `rpId`, `rpName`
and `allowedOrigins` you set on the DSL are never read. That is not a bug, but it is a
silent one: the configuration still compiles, still starts, and still points at whatever the
bean was constructed with.
[`RelyingPartyConfig`](../../passkeys/src/main/java/com/ankurm/passkeys/config/RelyingPartyConfig.java)
in this module exposes exactly such a bean under three profiles, which is why it repeats the
rpId and origin rather than sharing them with
[`SecurityConfig`](../../passkeys/src/main/java/com/ankurm/passkeys/config/SecurityConfig.java).
## Where the filters land
From [`docs/output/pk-filters.txt`](../output/pk-filters.txt), on the real running chain:
```
5 CsrfFilter
7 GenerateOneTimeTokenFilter
8 UsernamePasswordAuthenticationFilter
9 OneTimeTokenAuthenticationFilter
16 WebAuthnAuthenticationFilter
20 ExceptionTranslationFilter
21 PublicKeyCredentialCreationOptionsFilter
22 PublicKeyCredentialRequestOptionsFilter
23 AuthorizationFilter
24 WebAuthnRegistrationFilter
25 DefaultWebAuthnRegistrationPageGeneratingFilter
```
Note where the line falls. The two options filters sit **before** `AuthorizationFilter`;
`WebAuthnRegistrationFilter` sits **after** it. So `/webauthn/register/options` is answered
without an authorization check, and enforces its own requirement that the caller be
authenticated &mdash; which it does by throwing. See
[06 &mdash; The bootstrap problem](06-the-bootstrap-problem.md).
`CsrfFilter` at position 5 applies to all of them. Every WebAuthn endpoint is a `POST` that
changes server state (it stores a challenge), so every call needs a CSRF token. A front end
that fetches options without one gets a 403 and no explanation.
[&larr; 01 &mdash; Versions](01-versions.md) &middot; [index](README.md) &middot; next: [03 &mdash; The two ceremonies](03-the-two-ceremonies.md)

View File

@@ -0,0 +1,97 @@
[&larr; 02 &mdash; The minimum configuration](02-minimum-configuration.md) &middot; [index](README.md) &middot; next: [04 &mdash; A software authenticator](04-virtual-authenticator.md)
# The two ceremonies
WebAuthn has exactly two flows, and both have the same shape: the server issues a challenge,
the authenticator answers it, the server verifies the answer. Everything else is detail.
## Registration
```
POST /webauthn/register/options (authenticated session + CSRF token required)
-> challenge, rp, user, pubKeyCredParams, authenticatorSelection, attestation, timeout
and the challenge is stored in the HttpSession
navigator.credentials.create({ publicKey: options })
-> a key pair inside the authenticator, and an attestation object containing the public half
POST /webauthn/register { publicKey: { credential: {...}, label: "..." } }
-> { "success": true }, and a CredentialRecord in the UserCredentialRepository
```
The real options object, from a live run
([`docs/output/pk-ceremony.txt`](../output/pk-ceremony.txt)):
```json
{"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},
"challenge":"8H0qrJXIL_StIdOetvSm31hEcEy0NMoN8xz67u4UwFM","excludeCredentials":[],
"extensions":{"credProps":true},
"pubKeyCredParams":[{"alg":-8,"type":"public-key"},{"alg":-7,"type":"public-key"},{"alg":-257,"type":"public-key"}],
"rp":{"id":"localhost","name":"ankurm passkeys demo"},"timeout":300000,
"user":{"name":"user","id":"9RZ4HDuLE38GIFoTpth0hyAooU7i1HV49qJ46isEtAs","displayName":"user"}}
```
Everything in there is a Spring Security default, and every one of them is a decision:
| field | default | what it means |
|---|---|---|
| `attestation` | `none` | do not ask the authenticator to identify its make and model |
| `residentKey` | `required` | a discoverable credential, so the user need not type a username |
| `userVerification` | `preferred` | check a PIN or biometric **if convenient** &mdash; see [05](05-defaults.md) |
| `pubKeyCredParams` | EdDSA, ES256, RS256 | in that order of preference |
| `timeout` | 300000 ms | five minutes to complete the ceremony |
| `extensions` | `credProps` | ask the browser whether the credential ended up discoverable |
| `excludeCredentials` | this user's existing credentials | so the same authenticator is not enrolled twice |
`user.id` is a random 32-byte handle generated by
`Webauthn4JRelyingPartyOperations.findUserEntityOrCreateAndSave` on first use. It is **not**
the username, and it must not be: it is stored in the authenticator, it syncs to the user's
other devices, and it is visible to anything that can talk to the authenticator.
## Authentication
```
POST /webauthn/authenticate/options (CSRF token required; no session needed)
-> challenge, rpId, allowCredentials, userVerification, timeout
navigator.credentials.get({ publicKey: options })
-> authenticatorData, clientDataJSON, an ECDSA signature, and a userHandle
POST /login/webauthn { id, rawId, response: {...}, type: "public-key" }
-> { "authenticated": true, "redirectUrl": "/" } or a bare 401
```
```json
{"allowCredentials":[],"challenge":"18HVET3lRrveXvRdKS1K5k2Ji_3nqm1Krx_l3ZOB0wU","extensions":{},
"rpId":"localhost","timeout":300000,"userVerification":"preferred"}
```
`allowCredentials` is empty because the caller is anonymous:
`Webauthn4JRelyingPartyOperations.findCredentialRecords` returns an empty list when there is
no authenticated user, and the browser falls back to offering whatever discoverable
credentials it holds for that rpId. That empty array is the *usernameless* login most people
mean when they say &ldquo;passkey&rdquo;, and it only works because `residentKey` defaulted to
`required` during registration. The two settings are one decision made in two places.
## What the signature actually covers
```
signature = ECDSA-SHA256( authenticatorData || SHA-256(clientDataJSON) )
```
`authenticatorData` is `rpIdHash(32) || flags(1) || signCount(4)`, with attested credential
data appended during registration and omitted during assertion. `clientDataJSON` carries the
type, the challenge and the **origin** &mdash; and it is the browser, not the page, that fills
the origin in. That is the entire phishing defence: a credential minted for `bank.example` is
never offered to `bank-example.evil`, and even if it were, the origin in client data would not
match and the relying party would refuse. [07 &mdash; Failure modes](07-failure-modes.md)
shows that refusal happening.
## The bit that surprises people
Nothing above involves a password, and nothing above involves a username either &mdash; but
the registration ceremony required an authenticated session to even start. The credential is
bound to a user who was already identified some other way. See
[06 &mdash; The bootstrap problem](06-the-bootstrap-problem.md).
[&larr; 02 &mdash; The minimum configuration](02-minimum-configuration.md) &middot; [index](README.md) &middot; next: [04 &mdash; A software authenticator](04-virtual-authenticator.md)

View File

@@ -0,0 +1,96 @@
[&larr; 03 &mdash; The two ceremonies](03-the-two-ceremonies.md) &middot; [index](README.md) &middot; next: [05 &mdash; The defaults](05-defaults.md)
# A software authenticator, so the ceremonies can be executed
Every claim in these chapters comes from a run, which means something had to play the part of
the security key. Chrome's DevTools virtual authenticator can do it interactively; it cannot
be scripted into `run-all.sh`, and it cannot be checked into a repository.
[`VirtualAuthenticator`](../../passkeys/src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java)
is about two hundred lines and does the whole job. Spring Security and WebAuthn4J verify its
output without knowing that no hardware was involved &mdash; which is itself worth noticing.
## What it has to produce
**Authenticator data**, a fixed binary layout:
```
rpIdHash 32 bytes SHA-256 of the rpId string, not of the origin
flags 1 byte UP 0x01, UV 0x04, BE 0x08, BS 0x10, AT 0x40
signCount 4 bytes big endian
attestedCredData variable registration only: aaguid(16) || credIdLen(2) || credId || COSE key
```
**A COSE public key**, CBOR, for ES256:
```java
new Cbor().map(5)
.num(1).num(2) // kty: EC2
.num(3).num(-7) // alg: ES256
.num(-1).num(1) // crv: P-256
.num(-2).bytes(x) // 32 bytes, left-padded
.num(-3).bytes(y) // 32 bytes, left-padded
.toByteArray();
```
The left-padding matters. `BigInteger.toByteArray()` returns a two's-complement encoding: it
prepends a zero byte when the top bit is set, and it drops leading zero bytes when they are
not. Either way you get 31 or 33 bytes roughly half the time, and the relying party rejects
the key without telling you why.
**An attestation object**, also CBOR:
```java
new Cbor().map(3)
.text("fmt").text("none")
.text("attStmt").map(0)
.text("authData").bytes(authData)
.toByteArray();
```
178 bytes, in this module's case. [`Cbor`](../../passkeys/src/main/java/com/ankurm/passkeys/virtual/Cbor.java)
is a forty-line encoder covering the four CBOR major types WebAuthn needs; pulling in a
library for this would hide the structure rather than explain it.
**A signature** over `authenticatorData || SHA-256(clientDataJSON)`, using
`SHA256withECDSA`, which emits the DER encoding WebAuthn expects.
## What it deliberately does not do
It sets the UP and UV flags because it was asked to, not because anything happened. There is
no user presence test, no biometric, no secure element, and the private key sits in the heap
next to everything else.
That is not a shortcoming, it is the point. A relying party cannot tell the difference between
this and a real authenticator unless it verifies attestation &mdash; and by default Spring
Security does not. See [05 &mdash; The defaults](05-defaults.md).
## Driving it
[`tools/PasskeyCeremony.java`](../../passkeys/tools/PasskeyCeremony.java) is a single-file
source program (JEP 458, so it compiles its dependencies from the same directory on the fly).
It uses `java.net.http.HttpClient` with a real cookie jar and drives the actual HTTP endpoints,
CSRF tokens and all &mdash; not the operations bean directly &mdash; so filter ordering,
session handling and response codes are exercised too.
```bash
java --class-path "target/classes:$(cat target/deps.txt)" tools/PasskeyCeremony.java register-and-login
```
| scenario | what it shows |
|---|---|
| `register-and-login` | both ceremonies end to end |
| `clone-counter` | the signature counter, and what is done with it |
| `no-uv` | a credential whose UV flag was never set |
| `wrong-origin` / `wrong-origin-login` | a phishing attempt, in each ceremony |
| `duplicate` | `excludeCredentials`, and a client that ignores it |
| `bootstrap` | asking for registration options with nobody logged in |
| `ott` | the one-time-token fallback |
| `filters` | the live filter chain |
The [contract tests](../../passkeys/src/test/java/com/ankurm/passkeys/PasskeyContractTests.java)
use the same authenticator against `Webauthn4JRelyingPartyOperations` directly, with no HTTP
and no Spring context, so a failure there is the framework's behaviour rather than a filter
accident.
[&larr; 03 &mdash; The two ceremonies](03-the-two-ceremonies.md) &middot; [index](README.md) &middot; next: [05 &mdash; The defaults](05-defaults.md)

View File

@@ -0,0 +1,126 @@
[&larr; 04 &mdash; A software authenticator](04-virtual-authenticator.md) &middot; [index](README.md) &middot; next: [06 &mdash; The bootstrap problem](06-the-bootstrap-problem.md)
# The defaults, and what they do not check
Spring Security's passkey defaults are reasonable. They are also weaker than most people
assume, in ways that do not show up until you go looking.
## userVerification is `preferred`, which means optional
`Webauthn4JRelyingPartyOperations.createPublicKeyCredentialCreationOptions` builds:
```java
AuthenticatorSelectionCriteria.builder()
.userVerification(UserVerificationRequirement.PREFERRED)
.residentKey(ResidentKeyRequirement.REQUIRED)
.build();
```
and both `registerCredential` and `authenticate` decide whether to enforce UV like this:
```java
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
.equals(creationOptions.getAuthenticatorSelection().getUserVerification());
```
`PREFERRED` is not `REQUIRED`, so the flag is not checked. An authenticator that answers with
UV clear registers successfully and logs in successfully. From
[`docs/output/pk-user-verification.txt`](../output/pk-user-verification.txt):
```
$ POST /webauthn/register
HTTP 200
{"success":true}
$ GET /diag/credentials (note uvInitialized)
{"credentials":[{"label":"no-uv", ... "signatureCount":0,"uvInitialized":false, ...}]}
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
```
`uvInitialized: false` is recorded faithfully &mdash; and then nothing consults it. What you
have is a single-factor credential: possession of the authenticator, with no proof that the
person holding it is the enrolled user. For most consumer sites that is an acceptable trade
(it is roughly what a password gives you, minus the phishing). For anything where the passkey
*is* the second factor, it is not, and the fix is one setting on both ceremonies:
```java
operations.setCustomizeCreationOptions((options) -> options.authenticatorSelection(
AuthenticatorSelectionCriteria.builder()
.userVerification(UserVerificationRequirement.REQUIRED)
.residentKey(ResidentKeyRequirement.REQUIRED)
.build()));
operations.setCustomizeRequestOptions((options) ->
options.userVerification(UserVerificationRequirement.REQUIRED));
```
Setting only the first leaves logins unverified forever, which is the worst of the three
outcomes because registration looks correct. The `uvrequired` profile in this module sets
both; with it, the same authenticator is refused:
```
com.webauthn4j.verifier.exception.UserNotVerifiedException:
Verifier is configured to check user verified, but UV flag in authenticatorData is not set.
```
`AuthenticatorSelectionCriteria.builder()` has no copy constructor, so overriding
`userVerification` means restating `residentKey` too. Forget it and you silently drop back to
a non-discoverable credential, and usernameless login stops working.
## Asking for attestation is not verifying attestation
The default is `AttestationConveyancePreference.NONE`. Switch it to `DIRECT` and the options
change:
```json
{"attestation":"direct","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"}, ...}
```
Then register with an authenticator that answers `fmt: "none"` and an all-zero AAGUID. From
[`docs/output/pk-attestation.txt`](../output/pk-attestation.txt):
```
$ POST /webauthn/register
HTTP 200
{"success":true}
```
The reason is the manager. `Webauthn4JRelyingPartyOperations` initialises with:
```java
private WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager();
```
and that factory installs `NullFIDOU2FAttestationStatementVerifier`,
`NullPackedAttestationStatementVerifier`, `NullTPMAttestationStatementVerifier`,
`NullAndroidKeyAttestationStatementVerifier`, `NullAndroidSafetyNetAttestationStatementVerifier`,
`NullAppleAnonymousAttestationStatementVerifier`, a `NullCertPathTrustworthinessVerifier` and a
`NullSelfAttestationTrustworthinessVerifier`. Everything that could check an attestation
statement is a null object.
WebAuthn4J 0.31.9 does not ship a strict counterpart. `WebAuthnManager` has exactly two static
factories, `createNonStrictWebAuthnManager()` and its `ObjectConverter` overload; a strict
manager has to be assembled from real verifiers and a `TrustAnchorRepository` by hand, and
then `setWebAuthnManager` on the operations bean. That is a real project, and it is only worth
starting if you have an enterprise reason to care which model of key your users hold. For a
consumer product, attestation is usually the wrong thing to spend effort on &mdash; but you
should know that the `direct` in your options object is currently decorative.
## What *is* checked, by default, and correctly
Not everything is a caveat. Out of the box, all of these hold:
- **Origin.** A mismatch is rejected in both ceremonies. This is the phishing defence and it works.
- **Challenge.** Persisted in the `HttpSession` between the options call and the ceremony call, so a replayed or fabricated challenge fails.
- **rpId hash.** Compared against the configured relying party.
- **Signature.** Verified against the stored COSE key, with the algorithm the key declares.
- **User presence.** `userPresenceRequired` is hard-coded `true` at registration, per the specification.
- **Duplicate credential ids.** `registerCredential` rejects an id that already exists.
The unchecked list is short: user verification unless you ask for it, attestation unless you
build for it, and the signature counter &mdash; which gets its own chapter,
[10 &mdash; The signature counter](10-signature-counter.md).
[&larr; 04 &mdash; A software authenticator](04-virtual-authenticator.md) &middot; [index](README.md) &middot; next: [06 &mdash; The bootstrap problem](06-the-bootstrap-problem.md)

View File

@@ -0,0 +1,106 @@
[&larr; 05 &mdash; The defaults](05-defaults.md) &middot; [index](README.md) &middot; next: [07 &mdash; Failure modes](07-failure-modes.md)
# The bootstrap problem
A passkey cannot be a user's first credential. This is not a Spring Security limitation, it is
the shape of the protocol, and it is the thing that turns &ldquo;add passkeys&rdquo; from an
afternoon into a project.
## Where it is enforced
```java
Authentication authentication = request.getAuthentication();
if (!this.trustResolver.isAuthenticated(authentication)) {
throw new IllegalArgumentException("Authentication must be authenticated");
}
```
&mdash; `Webauthn4JRelyingPartyOperations.createPublicKeyCredentialCreationOptions`
`AuthenticationTrustResolverImpl.isAuthenticated` returns false for null and for anonymous, so
registration options are only issued to a session that already proved who it belongs to. And
they have to be: the options object contains `user.id` and `user.name`, and the credential is
bound to them. There is nobody to bind to until somebody has logged in.
From [`docs/output/pk-bootstrap.txt`](../output/pk-bootstrap.txt):
```
$ POST /webauthn/register/options (anonymous)
HTTP 400
(empty body)
```
Note the status. Not 401, not a redirect to the login page &mdash; a bare **400** with no
body, because `PublicKeyCredentialCreationOptionsFilter` sits before `AuthorizationFilter` and
the `IllegalArgumentException` surfaces as a bad request. A single-page front end whose session
has expired will see a 400 from an endpoint that worked a minute ago, and nothing about that
response says &ldquo;log in again&rdquo;. Handle it explicitly on the client.
## So what carries the user in?
Something else always does. The realistic options, in rough order of how often they are the
right answer:
| route | good for | cost |
|---|---|---|
| **One-time token** by email or SMS | new signups, and recovery after a lost device | you own a delivery channel and its failure modes |
| **Existing password** | adding passkeys to an application that already has users | the password stays, so the phishing surface stays |
| **Federated login** (OIDC, social) | consumer products that already federate | the identity provider becomes your recovery story |
| **Support desk** | enterprises with an existing identity-proofing process | expensive, and now the desk is the attack surface |
This module uses the first, because Spring Security ships it: `oneTimeTokenLogin` is one line
of DSL, and it covers both the way in and the way back. See
[08 &mdash; The one-time-token fallback](08-one-time-token-fallback.md).
## The recovery half is the hard half
Registration is a solved problem the moment the user is logged in. Recovery is not, and the
awkward truth is that **your recovery path sets your real security level**. A passkey that
cannot be phished, backed by an email magic link that can be, is an application whose security
is that of the email account.
The mitigations are all trade-offs, and none of them are free:
- **Require two passkeys.** A phone and a laptop, or a phone and a hardware key. Excellent, and roughly half of users will not do it.
- **Rate-limit and age-limit recovery.** A magic link that logs you in but will not register a new passkey for 24 hours turns an account takeover into a race you can notice.
- **Notify on registration.** Any new credential produces a mail to every address on file. Cheap, and it is how takeovers actually get caught.
- **Step up for sensitive actions.** Recovery gets you in; it does not get you a password change or a payout.
Spring Security gives you the primitives for the last two: `AuthenticationSuccessHandler` for
notification, and `FactorGrantedAuthority` for step-up. A session established by one-time token
carries `FACTOR_OTT`; one established by a passkey carries `FACTOR_WEBAUTHN`. From
[`docs/output/pk-ott.txt`](../output/pk-ott.txt) and
[`docs/output/pk-ceremony.txt`](../output/pk-ceremony.txt):
```json
{"name":"user","authenticationType":"OneTimeTokenAuthentication",
"authorities":["FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=...]","ROLE_USER"]}
{"name":"user","authenticationType":"WebAuthnAuthentication",
"authorities":["FactorGrantedAuthority [authority=FACTOR_WEBAUTHN, issuedAt=...]","ROLE_USER"]}
```
Those are ordinary authorities, so `hasAuthority("FACTOR_WEBAUTHN")` is all it takes to
require a real passkey on an endpoint that a magic link should not reach:
```java
.requestMatchers("/passkey-only").hasAuthority("FACTOR_WEBAUTHN")
```
What happens when the authority is missing is better than a 403. `WebAuthnConfigurer.init`
registers `defaultDeniedHandlerForMissingAuthority(.., FactorGrantedAuthority.WEBAUTHN_AUTHORITY)`
with a `LoginUrlAuthenticationEntryPoint("/login")`, so Spring Security turns "you are
authenticated but not with a passkey" into a redirect that says which factor is missing. From
[`docs/output/pk-step-up.txt`](../output/pk-step-up.txt), the same user in three sessions:
```
password session -> GET /passkey-only: HTTP 302, Location: /login?factor.type=webauthn&factor.reason=missing
one-time-token session -> GET /passkey-only: HTTP 302, Location: /login?factor.type=webauthn&factor.reason=missing
passkey session -> GET /passkey-only: HTTP 200 {"ok":"this endpoint required FACTOR_WEBAUTHN"}
```
Those query parameters are generated for you and are not mentioned on the passkeys reference
page. A login page that reads `factor.reason=missing` can say &ldquo;use your passkey for
this&rdquo; instead of &ldquo;access denied&rdquo;.
[&larr; 05 &mdash; The defaults](05-defaults.md) &middot; [index](README.md) &middot; next: [07 &mdash; Failure modes](07-failure-modes.md)

View File

@@ -0,0 +1,92 @@
[&larr; 06 &mdash; The bootstrap problem](06-the-bootstrap-problem.md) &middot; [index](README.md) &middot; next: [08 &mdash; The one-time-token fallback](08-one-time-token-fallback.md)
# Failure modes, and what each one looks like
Passkey failures are quiet. The authentication endpoint returns a bare 401 with no body, the
registration endpoint returns a 500 with a generic error page, and the reason is only ever in
the server log at DEBUG. This chapter is a lookup table.
Run with the `trace` profile to see any of it:
```bash
./scripts/run.sh trace
```
## Every registration failure is an HTTP 500
This is worth stating on its own, because it is the first thing that confuses people.
`WebAuthnRegistrationFilter` has no error handling: whatever WebAuthn4J throws propagates out
of the filter and Boot's error page turns it into a 500. Three different mistakes, three
identical responses ([`pk-origin.txt`](../output/pk-origin.txt),
[`pk-user-verification.txt`](../output/pk-user-verification.txt),
[`pk-duplicate.txt`](../output/pk-duplicate.txt)):
```
$ POST /webauthn/register
HTTP 500
{"timestamp":"...","status":500,"error":"Internal Server Error","path":"/webauthn/register"}
```
Authentication is different. `WebAuthnAuthenticationProvider` catches everything:
```java
catch (RuntimeException ex) {
throw new BadCredentialsException(ex.getMessage(), ex);
}
```
so the client gets a clean `401` with an empty body, and the cause is discarded before it
reaches any response the browser can see. Both behaviours mean the same thing: **read the
server log, not the HTTP response**.
## The table
| symptom | cause | where to look |
|---|---|---|
| `HTTP 400`, empty body, on `/webauthn/register/options` | nobody is logged in, or the session expired | `IllegalArgumentException: Authentication must be authenticated` |
| `HTTP 403` on any `/webauthn/**` POST | missing CSRF token &mdash; all five endpoints are state-changing POSTs | `CsrfFilter` at position 5 |
| `HTTP 500` on `/webauthn/register` | origin mismatch | `BadOriginException: The collectedClientData origin '...' doesn't match expected: ...` |
| `HTTP 500` on `/webauthn/register` | UV required, authenticator did not verify | `UserNotVerifiedException: ... UV flag in authenticatorData is not set` |
| `HTTP 500` on `/webauthn/register` | the credential id is already registered | `IllegalArgumentException: Credential with id ... already exists` |
| `HTTP 401`, empty body, on `/login/webauthn` | origin mismatch, bad signature, unknown credential id, or a null stored attestation object | `BadCredentialsException` wrapping the real cause |
| `navigator.credentials.create` never prompts | not a secure context &mdash; plain HTTP on anything but `localhost` | browser console, not the server |
| credentials vanish on restart | `MapUserCredentialRepository`, the default | [09 &mdash; Persistence](09-persistence.md) |
| `NullPointerException` during registration | an authenticator transport Spring does not know, e.g. `cable` | [spring-security#19366](https://github.com/spring-projects/spring-security/issues/19366) |
| WebAuthn breaks under Spring Session with Redis | creation options serialisation | [spring-security#16328](https://github.com/spring-projects/spring-security/issues/16328) |
## The origin mismatch, in full
This is the one that matters, because it is the phishing defence working. From
[`docs/output/pk-origin.txt`](../output/pk-origin.txt), with the client sending
`http://evil.localhost:8080` and the relying party configured for `http://localhost:8080`:
```
com.webauthn4j.verifier.exception.BadOriginException: The collectedClientData origin
'http://evil.localhost:8080' doesn't match expected: http://localhost:8080
at com.webauthn4j.verifier.OriginVerifierImpl.verify(OriginVerifierImpl.java:72)
at com.webauthn4j.verifier.RegistrationDataVerifier.verify(RegistrationDataVerifier.java:171)
```
and the same mistake one ceremony later:
```
$ POST /login/webauthn (origin http://evil.localhost:8080)
HTTP 401
(empty body)
```
In a real browser this failure is not reachable: the browser writes the origin itself and the
credential is scoped to an rpId, so a phishing page never gets an assertion to send. The
software authenticator in this module can lie about its origin precisely so the server-side
half of the check can be watched.
## Debugging checklist
1. Is the rpId a domain, and does it match or registrably-suffix the origin's host?
2. Is the allowed origin the exact string, with scheme and port?
3. Is there a `WebAuthnRelyingPartyOperations` bean quietly overriding the DSL? ([02](02-minimum-configuration.md))
4. Is the request a POST with a CSRF token?
5. Is the session the same one that received the challenge? The default `PublicKeyCredentialCreationOptionsRepository` is `HttpSession`-backed, so a load balancer without sticky sessions breaks registration and nothing else.
6. Turn on `logging.level.com.webauthn4j: DEBUG` and read the actual exception.
[&larr; 06 &mdash; The bootstrap problem](06-the-bootstrap-problem.md) &middot; [index](README.md) &middot; next: [08 &mdash; The one-time-token fallback](08-one-time-token-fallback.md)

View File

@@ -0,0 +1,99 @@
[&larr; 07 &mdash; Failure modes](07-failure-modes.md) &middot; [index](README.md) &middot; next: [09 &mdash; Persistence](09-persistence.md)
# The one-time-token fallback
`oneTimeTokenLogin` is Spring Security's magic link. It exists independently of passkeys, but
it is the natural partner: it solves the bootstrap problem in [06](06-the-bootstrap-problem.md)
and it is the recovery path when a device is lost.
```java
.oneTimeTokenLogin((ott) -> ott.tokenGenerationSuccessHandler(handler))
```
## It will not start without a delivery handler
There is no default `OneTimeTokenGenerationSuccessHandler`, and there cannot be a sensible one
&mdash; Spring has no idea whether you send email, SMS or a push. Omit the bean and the
context fails to start. That is the correct decision and it is also the first error everybody
hits.
The handler in this module prints the link and writes the token to a file so the demo scripts
can read it:
```java
System.out.printf("[one-time-token] username=%s expires=%s%n[one-time-token] %s%n",
oneTimeToken.getUsername(), oneTimeToken.getExpiresAt(), link);
Files.writeString(TOKEN_FILE, oneTimeToken.getTokenValue(), StandardCharsets.UTF_8);
this.redirect.handle(request, response, oneTimeToken);
```
The `redirect.handle(..)` at the end is not optional: the handler owns the HTTP response, so
if it does not write one the browser gets a blank page.
`RedirectOneTimeTokenGenerationSuccessHandler("/login/ott")` sends the user to the built-in
submit page.
## The flow, and the endpoints
| method | path | what it does |
|---|---|---|
| `POST` | `/ott/generate` | takes `username`, generates a token, calls your handler |
| `GET` | `/login/ott` | the built-in submit page, prefilled if `?token=` is present |
| `POST` | `/login/ott` | redeems the token and creates the session |
From [`docs/output/pk-ott.txt`](../output/pk-ott.txt):
```
POST /ott/generate -> HTTP 302, Location: http://localhost:8080/login/ott
token delivered out of band (the handler wrote it to a file): 3203202f-7e17-4f8b-a613-0d64831c3530
POST /login/ott -> HTTP 302, Location: http://localhost:8080/
$ GET /me
{"name":"user","authenticationType":"OneTimeTokenAuthentication",
"authorities":["FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=...]","ROLE_USER"]}
```
Single use is enforced &mdash; `OneTimeTokenService.consume` removes it:
```
=== The same token, a second time ===
POST /login/ott -> HTTP 302, Location: http://localhost:8080/login?error
```
## It does not tell an attacker whether the account exists
`InMemoryOneTimeTokenService.generate` takes a `GenerateOneTimeTokenRequest` carrying a
username and nothing else. There is no `UserDetailsService` involved, so it cannot check
whether the user exists, and it does not try. From
[`docs/output/pk-bootstrap.txt`](../output/pk-bootstrap.txt), for a username that is not in
the `UserDetailsService`:
```
POST /ott/generate -> HTTP 302, Location: http://localhost:8080/login/ott
a token was still generated and delivered: f9f58b17-f2ee-498c-86fe-da3570f9108e
POST /login/ott -> HTTP 302, Location: http://localhost:8080/login?error (the failure lands here instead)
```
Byte-identical to a real username, so there is no account-enumeration oracle at the generate
endpoint. The failure happens at redemption, in `OneTimeTokenAuthenticationProvider`, where
nobody is listening.
This falls out of the design rather than being aimed at, and it is currently an open question
in the project &mdash; [spring-security#16483](https://github.com/spring-projects/spring-security/issues/16483)
argues that a token should not be created for a user who does not exist. If that changes, check
that the response for an unknown username still matches the response for a known one, because
it is very easy to fix the storage waste and open an enumeration hole in the same commit.
## Defaults worth changing
| setting | default | why you might move it |
|---|---|---|
| token TTL | 5 minutes (`GenerateOneTimeTokenRequest`) | shorter for recovery, since the mail arrives in seconds |
| `OneTimeTokenService` | `InMemoryOneTimeTokenService` | `JdbcOneTimeTokenService` for more than one instance; the DDL is `org/springframework/security/core/ott/jdbc/one-time-tokens-schema.sql` |
| token value | a UUID | `GenerateOneTimeTokenRequestResolver` if you need a short numeric code for SMS |
| rate limiting | none | there is none. `/ott/generate` will happily mail somebody a hundred links |
That last row is the one to act on. A magic-link endpoint with no rate limit is a mail bomb
and a nuisance-denial-of-service against your own sending reputation. Spring Security does not
ship a limiter; put one in front.
[&larr; 07 &mdash; Failure modes](07-failure-modes.md) &middot; [index](README.md) &middot; next: [09 &mdash; Persistence](09-persistence.md)

View File

@@ -0,0 +1,93 @@
[&larr; 08 &mdash; The one-time-token fallback](08-one-time-token-fallback.md) &middot; [index](README.md) &middot; next: [10 &mdash; The signature counter](10-signature-counter.md)
# 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
```java
@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 &mdash; the two DDL files stayed in
`spring-security-web` when the classes moved out in 7.0 ([01](01-versions.md)). They are still
on the classpath transitively, so this works:
```yaml
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`](../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:
```java
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 &mdash; a reasonable-looking optimisation, since the public key is right there in its
own column &mdash; 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](10-signature-counter.md).
## 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 &mdash; [spring-security#16328](https://github.com/spring-projects/spring-security/issues/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.
[&larr; 08 &mdash; The one-time-token fallback](08-one-time-token-fallback.md) &middot; [index](README.md) &middot; next: [10 &mdash; The signature counter](10-signature-counter.md)

View File

@@ -0,0 +1,138 @@
[&larr; 09 &mdash; Persistence](09-persistence.md) &middot; [index](README.md) &middot; next: [11 &mdash; Should you build this](11-should-you.md)
# The signature counter is stored on every login and compared against on none
WebAuthn's signature counter exists for one purpose: detecting a cloned authenticator. A
hardware key increments a monotonic counter each time it signs, so if the relying party ever
sees a counter it has already seen, two copies of the private key are in circulation.
Spring Security persists that counter faithfully. It never reads it back for verification.
## Watching it happen
[`scripts/counter.sh`](../../passkeys/scripts/counter.sh) registers one credential, then
authenticates three times with counters 1, 2 and 3, checking what the relying party stored
after each. Then it presents a counter of 1 again. From
[`docs/output/pk-counter.txt`](../output/pk-counter.txt):
```
--- assertion 1, authenticator signCount = 1
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
stored signatureCount now: 1
--- assertion 2, authenticator signCount = 2
stored signatureCount now: 2
--- assertion 3, authenticator signCount = 3
stored signatureCount now: 3
=== Replaying a stale counter. A cloned key would look exactly like this ===
--- assertion 4, authenticator signCount = 1 (lower than the stored 3)
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
replayed a counter of 1 after the relying party had stored 3: HTTP 200
```
The stored value walks 1, 2, 3 exactly as you would hope. Then a counter of 1 is accepted.
## Why
WebAuthn4J *does* implement the check. `AuthenticationDataVerifier` follows the specification
line by line:
```java
long presentedSignCount = authenticatorData.getSignCount();
long storedSignCount = authenticator.getCounter();
if (presentedSignCount > 0 || storedSignCount > 0) {
if (presentedSignCount > storedSignCount) { ... }
else { maliciousCounterValueHandler.maliciousCounterValueDetected(authenticationObject); }
}
```
and the default handler throws `MaliciousCounterValueException` with the message
&ldquo;Malicious counter value is detected. Cloned authenticators exist in parallel.&rdquo;
The gap is in what `authenticator.getCounter()` returns. Spring builds the WebAuthn4J
credential record like this:
```java
AttestationObject wa4jAttestationObject = cborConverter.readValue(attestationObject.getBytes(), AttestationObject.class);
com.webauthn4j.credential.CredentialRecord wa4jCredentialRecord =
new CredentialRecordImpl(wa4jAttestationObject, null, null, transports);
```
&mdash; `Webauthn4JRelyingPartyOperations.authenticate`, Spring Security 7.1.1
and that constructor derives the counter from the attestation object:
```java
public CoreCredentialRecordImpl(@NotNull AttestationObject attestationObject) {
super(attestationObject.getAuthenticatorData().getAttestedCredentialData(),
attestationObject.getAttestationStatement(),
attestationObject.getAuthenticatorData().getSignCount(), // <-- the counter
attestationObject.getAuthenticatorData().getExtensions());
```
&mdash; `CoreCredentialRecordImpl`, WebAuthn4J 0.31.9
The attestation object is the one captured at **registration**. Its counter is frozen at
whatever the authenticator reported then &mdash; almost always 0. So the comparison is always
&ldquo;is the presented counter greater than 0?&rdquo;, the persisted `signature_count` column
is never consulted, and the only assertion that would ever trip the check is one presenting a
counter of exactly 0 against a registration counter of 0. Which is precisely what every
synced passkey does, on every login, forever &mdash; and those are not rejected either, because
of the `presentedSignCount > 0 || storedSignCount > 0` guard.
The contract test
[`signatureCounterIsStoredButNotVerified`](../../passkeys/src/test/java/com/ankurm/passkeys/PasskeyContractTests.java)
pins this at the operations level, with no HTTP involved, and will start failing the day it
changes.
## How much does this matter?
Less than the paragraphs above suggest, and it is worth being precise about why.
**The specification treats a mismatch as a signal, not a verdict.** WebAuthn Level 3 does make
the comparison a step in the assertion verification procedure. What it does *not* do is
prescribe the response: a counter that fails to increase is &ldquo;a signal, but not proof,
that the authenticator may be cloned&rdquo;, since it might equally be a malfunctioning
authenticator or assertions processed out of order, and relying parties are told to
&ldquo;evaluate their own operational characteristics and incorporate this information into
their risk scoring&rdquo;. Spring Security sits at the permissive end of that range rather
than outside it.
**Modern passkeys have no counter.** A credential synced through iCloud Keychain, Google
Password Manager or 1Password exists on several devices by design; a monotonic counter is
meaningless across them, so those authenticators report 0 permanently. For a consumer
application where most credentials are syncable, counter checking would detect nothing and
would risk false positives.
**It is still worth knowing.** If you are enrolling FIDO2 hardware keys in an enterprise
setting, the counter is real, it increments, and the check is a genuine clone detector &mdash;
and you do not have it. The `signature_count` column filling up correctly makes it look like
you do, which is the actual hazard here: not a hole, but a control you might believe you have.
## If you need it
Nothing in the API blocks you. Wrap the operations bean:
```java
@Bean
WebAuthnRelyingPartyOperations relyingPartyOperations(UserCredentialRepository credentials, ...) {
Webauthn4JRelyingPartyOperations delegate = new Webauthn4JRelyingPartyOperations(...);
return new CounterCheckingRelyingPartyOperations(delegate, credentials);
}
```
where the wrapper reads `credentials.findByCredentialId(id).getSignatureCount()` before
delegating, remembers it, and after `authenticate` returns compares it against the value the
delegate has just written &mdash; rejecting the login if it did not increase and either value
was non-zero. That reproduces the specification's rule using the column Spring already
maintains. Alert rather than block if syncable passkeys are in the mix; a permanent 0 must not
lock anybody out.
[&larr; 09 &mdash; Persistence](09-persistence.md) &middot; [index](README.md) &middot; next: [11 &mdash; Should you build this](11-should-you.md)

View File

@@ -0,0 +1,49 @@
[&larr; 10 &mdash; The signature counter](10-signature-counter.md) &middot; [index](README.md)
# Should you build this
## Yes, if
- **You have consumer users and a password reset problem.** Passkeys remove credential stuffing and phishing in one move, and both are probably in your incident history.
- **You already federate.** If most users arrive through Google or Apple, they already have a passkey-capable account recovery story, and you are adding a first-party option rather than inventing one.
- **You can afford a second credential.** Passkeys are excellent when a user has two. They are a support burden when a user has one and drops their phone in a canal.
- **Your users are on current devices.** Platform authenticator support is effectively universal on hardware from the last few years, and effectively absent below it.
## No, or not yet, if
- **Your recovery path is an email magic link and nothing else.** Then your security level is your users' email accounts, and you have added complexity without adding strength. Fix recovery first; the passkeys will still be there.
- **You cannot run HTTPS everywhere, including in development.** `localhost` is the only exception browsers make. A staging environment on plain HTTP simply cannot run the ceremony.
- **You are behind a load balancer without sticky sessions and cannot add shared session storage.** The challenge lives in the `HttpSession`. Registration will fail intermittently and the logs will not obviously say why.
- **You need attestation.** Verifying which model of authenticator a user holds is a real project on top of what Spring Security gives you ([05](05-defaults.md)), and requires a trust-anchor source such as the FIDO Metadata Service.
- **You are counting on clone detection.** You do not have it ([10](10-signature-counter.md)).
## What it actually costs
The Spring Security part is genuinely small: one dependency, one DSL block, one
`UserDetailsService`. Working passkey login in an afternoon is a fair estimate, and the
built-in registration and login pages mean you can demonstrate it before writing any
JavaScript.
The rest is not small:
| work | why it is not optional |
|---|---|
| a delivery channel for one-time tokens | the bootstrap problem ([06](06-the-bootstrap-problem.md)) |
| a credential management UI | list, label, delete, and refuse to delete the last one |
| recovery policy, rate limits, notifications | this is where your real security level is set |
| a real front end | the built-in pages are a reference, not a product |
| persistence and backups | the credential table is the account ([09](09-persistence.md)) |
| a fallback for unsupported clients | which means keeping passwords or federation alive during the migration |
The honest framing is that Spring Security 7.1 has removed the protocol from your list of
problems, and left you with all the product ones. That is a good trade &mdash; it is just not
the same as being finished.
## Where to go next
- [`docs/01`&ndash;`18`](../) &mdash; JWT authentication and OAuth2 resource servers, the other two projects in this repository
- [`docs/authorization-server/`](../authorization-server/README.md) &mdash; running your own OAuth2 / OIDC provider, which is the other way to solve the bootstrap problem
- [WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) &mdash; the specification, and readable
- [passkeys.dev](https://passkeys.dev) &mdash; device and browser support, and the UX conventions users now expect
[&larr; 10 &mdash; The signature counter](10-signature-counter.md) &middot; [index](README.md)

70
docs/passkeys/README.md Normal file
View File

@@ -0,0 +1,70 @@
# Passkeys and WebAuthn with Spring Security 7.1
Companion documentation for
[Passkeys and WebAuthn with Spring Security 7](https://ankurm.com/passkeys-webauthn-spring-security-7/)
on ankurm.com, and for the code in [`passkeys/`](../../passkeys).
The other three projects in this repository move bearer tokens around. This one gets rid of
the password &mdash; and then spends most of its length on the parts that are not the
ceremony, because the ceremony is the easy half.
Everything here was run. There is no browser and no hardware key anywhere in this module: a
[software authenticator](04-virtual-authenticator.md) produces genuine CBOR attestation objects
and genuine ES256 assertion signatures, and Spring Security verifies them without noticing.
| | |
|---|---|
| JDK | Temurin **25.0.4.1+1** (current LTS) |
| Spring Boot | **4.1.1** |
| Spring Framework | **7.0.9** |
| Spring Security | **7.1.1** (GA 20 August 2026) |
| `spring-security-webauthn` | **7.1.1** &mdash; a separate artifact since 7.0 |
| WebAuthn4J | **0.31.9.RELEASE** |
| Jackson | **3.1.5** (`tools.jackson`) |
| Tomcat | **11.0.24** |
| Maven | 3.9.11 |
Every file in [`docs/output/pk-*.txt`](../output) is real program output, regenerated by
[`passkeys/scripts/run-all.sh`](../../passkeys/scripts/run-all.sh).
## Chapters
| # | chapter | what it settles |
|---|---|---|
| 01 | [Versions, artifacts and the 7.0 split](01-versions.md) | the dependency `spring-boot-starter-security` does not give you |
| 02 | [The minimum configuration](02-minimum-configuration.md) | six endpoints from one DSL block, and the bean that silently disables it |
| 03 | [The two ceremonies](03-the-two-ceremonies.md) | what is on the wire, and what every default in the options object means |
| 04 | [A software authenticator](04-virtual-authenticator.md) | how to execute a passkey ceremony in CI, with no browser |
| 05 | [The defaults](05-defaults.md) | user verification is optional, and asking for attestation is not checking it |
| 06 | [The bootstrap problem](06-the-bootstrap-problem.md) | a passkey cannot be a user's first credential |
| 07 | [Failure modes](07-failure-modes.md) | why every registration failure is a 500 and every login failure is a bare 401 |
| 08 | [The one-time-token fallback](08-one-time-token-fallback.md) | the way in, the way back, and the rate limit that does not exist |
| 09 | [Persistence](09-persistence.md) | the in-memory default, the missing DDL, and the column you must not drop |
| 10 | [The signature counter](10-signature-counter.md) | stored on every login, compared against on none |
| 11 | [Should you build this](11-should-you.md) | the honest answer, and what the afternoon actually costs |
## Captured output
| file | produced by | shows |
|---|---|---|
| [`pk-ceremony.txt`](../output/pk-ceremony.txt) | `scripts/ceremony.sh` | registration and authentication, end to end |
| [`pk-counter.txt`](../output/pk-counter.txt) | `scripts/counter.sh` | a stale signature counter being accepted |
| [`pk-user-verification.txt`](../output/pk-user-verification.txt) | `scripts/user-verification.sh` | `preferred` versus `required`, same authenticator |
| [`pk-origin.txt`](../output/pk-origin.txt) | `scripts/origin.sh` | the phishing defence, in both ceremonies |
| [`pk-attestation.txt`](../output/pk-attestation.txt) | `scripts/attestation.sh` | `direct` requested, `none` accepted |
| [`pk-duplicate.txt`](../output/pk-duplicate.txt) | `scripts/duplicate.sh` | `excludeCredentials`, and a client that ignores it |
| [`pk-bootstrap.txt`](../output/pk-bootstrap.txt) | `scripts/bootstrap.sh` | registration options with nobody logged in |
| [`pk-step-up.txt`](../output/pk-step-up.txt) | `scripts/step-up.sh` | `FACTOR_WEBAUTHN` versus `FACTOR_OTT` on one endpoint |
| [`pk-ott.txt`](../output/pk-ott.txt) | `scripts/ott-fallback.sh` | generate, redeem, and redeem again |
| [`pk-jdbc.txt`](../output/pk-jdbc.txt) | `scripts/jdbc.sh` | the same ceremony against H2 |
| [`pk-filters.txt`](../output/pk-filters.txt) | `scripts/filters.sh` | the live filter chain, all 25 of it |
| [`pk-test-run.txt`](../output/pk-test-run.txt) | `scripts/test-run.sh` | seven contract tests |
## Quickstart
```bash
cd spring-auth-demo/passkeys
./scripts/run.sh # http://localhost:8080/login, user/password
./scripts/ceremony.sh # both ceremonies, no browser
./scripts/run-all.sh # regenerate every docs/output/pk-*.txt
```