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

4.7 KiB

← 07 — Failure modes · index · next: 09 — Persistence

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 and it is the recovery path when a device is lost.

.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:

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:

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, 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 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 · index · next: 09 — Persistence