[← 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)