1
0
Files
spring-auth-demo/docs/passkeys/08-one-time-token-fallback.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

100 lines
4.7 KiB
Markdown

[← 07 — Failure modes](07-failure-modes.md) · [index](README.md) · next: [09 — 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
— 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 — `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 — [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.
[← 07 — Failure modes](07-failure-modes.md) · [index](README.md) · next: [09 — Persistence](09-persistence.md)