You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(verify): one-shot consumption of email confirmation tokens
The email confirmation JWT issued by VerifyHandler.sendConfirmation was
only protected by its 30-minute expiry: any party who could read the
confirmation link (forwarded email, mail-gateway logs, mailbox archive)
could redeem it independently within the window, creating a separate
authenticated session for the same address.
Add a VerifConfirmationStore interface (MarkUsed key, ttl -> bool) and a
default in-memory implementation (sync.Mutex-protected map keyed by
SHA-256 of the raw token). When VerifyHandler.ConfirmationStore is
non-nil, LoginHandler records the token as consumed on first redemption
and rejects replays with 403 "confirmation token already consumed".
The store key is the SHA-256 of the raw token rather than a jti claim,
so existing token fixtures (which carry no jti) are still de-dup'd
correctly without changing the wire format.
Wired through Service.AddVerifProvider in both auth.go and v2/auth.go.
The store is selected once via sync.Once, with this precedence:
1. Opts.VerifConfirmationStore if non-nil (caller-supplied; required
for multi-instance deployments — Redis or any shared KV).
2. provider.NewInMemoryVerifStore() default — fine for single
instance, broken for LB-fronted multi-instance deployments where
instance A's "used" set is unknown to instance B. Documented in
README under "Confirmation token replay protection".
Tests:
* TestInMemoryVerifStore — store-level coverage (replay, expiry, key
independence).
* TestVerifyHandler_LoginAcceptConfirm_RejectsReplay — integration:
same token redeemed twice -> 200 then 403.
* TestService_AddVerifProvider_UsesCustomConfirmationStore — Opts
injection works.
* TestService_AddVerifProvider_DefaultsToInMemory — default install
+ sync.Once reuse on subsequent AddVerifProvider calls.
Copy file name to clipboardExpand all lines: README.md
+19Lines changed: 19 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -296,6 +296,25 @@ The API for this provider:
296
296
297
297
The provider acts like any other, i.e. will be registered as `/auth/email/login`.
298
298
299
+
#### Confirmation token replay protection
300
+
301
+
Confirmation tokens are one-shot: a token redeemed once cannot be redeemed again within its TTL. This stops anyone with read access to the email link (forwarded mail, mail-gateway logs, mailbox archive) from independently consuming it after the user has.
302
+
303
+
By default the library installs an in-memory store on first call to `AddVerifProvider`. This is correct for **single-instance** deployments.
304
+
305
+
**Multi-instance deployments behind a load balancer MUST supply a shared backend.** With the default in-memory store, replay protection works only on the instance that originally consumed the token; an attacker who hits any other instance can still replay within the TTL. **This is silent: the request succeeds and the auth flow completes normally, with no log indicating the protection was bypassed.** Plug in Redis or any shared KV by setting `Opts.VerifConfirmationStore` to a value implementing `provider.VerifConfirmationStore`:
306
+
307
+
```go
308
+
typeVerifConfirmationStoreinterface {
309
+
// MarkUsed records key as consumed and returns alreadyUsed=true if it was
The store key is the SHA-256 of the raw confirmation token, so existing tokens issued before this protection landed are de-dup'd correctly without changing the wire format. Consumption is final: a transient downstream failure after the mark burns the token; the user must request a new confirmation email rather than retry the same link.
317
+
299
318
#### Email-as-identity caveat
300
319
301
320
The verify provider returns a local user id of the form `<provider>_<sha1(address)>`. The confirmation round-trip proves current control of the address at the moment of login; it does **not** prove stable+unique identity over time. The owner of an address can change without the address changing:
0 commit comments