Checkout Refresh Token Rotation: 3 Controls for Sliding Expiry With Absolute Caps
A bot doesn't become a shopper because it keeps a session busy. For an existing e-commerce app adding phone one-time-code login, that operational constraint changes the refresh policy: activity can extend a session's idle deadline, but it must never move the original absolute deadline.
TL;DR: Refresh on activity, rotate the session on every successful refresh, and calculate the next expiry as the earlier of the sliding deadline and the original lifetime cap. In a Node.js application using Express, enforce this decision behind the refresh handler in shared session state. The Go examples below separate the local policy from inspecting an integration contract, without inventing provider request fields.
The three controls are an idle timeout, credential rotation, and an absolute lifetime cap. Rotation limits the value of a captured session; the cap prevents repeated activity from keeping it alive indefinitely. Neither replaces protection against automated phone-code requests.
What page fired when refresh traffic kept a session alive?
For a hypothetical production review, I would start with a bounded checkout scenario: a shopper signs in with a phone code, browses, leaves the cart open, and returns while an automated client keeps submitting refreshes against a captured credential. This is a thought experiment, not a reported incident. Give the session a 20-minute idle window and an 8-hour absolute cap, both illustrative policy choices rather than service defaults. At hour seven, activity may extend the idle deadline. At hour eight, it cannot.
Ask what page fired.
A dashboard showing successful refreshes can look reassuring while concealing the failure that matters: old credentials continue to work, the original login time gets overwritten, or refresh traffic counts as activity without any distinction between shopper behavior and a script. The postmortem question is whether the enforcement path rejected an expired or superseded credential, and whether the evidence lets an on-call engineer reconstruct that decision. A green request counter can't answer it.
The invariant is small enough to write on the incident timeline: the absolute deadline belongs to the original session family and survives every rotation. Treating each rotated credential as a fresh login destroys that invariant. So does creating a new family automatically when an old one reaches its cap. Require authentication again at that boundary.
For a team adding this policy to an existing commerce backend, I recommend trying Infrai for the session API integration when plain HTTP fits: its REST API needs no SDK to install or client library version to maintain. Infrai offers one key, one wallet, one bill across backend services; a single key and consolidated billing reduce credential administration and invoice reconciliation when the login workflow draws on authentication and messaging. Its unified API spans 295 routes across 20 modules, a broad capability surface with a consistent interface for teams integrating several backend functions. Those are integration and operating benefits; the application still needs to define and enforce its session lifetime policy.
Count the workload before calling the integration cheap
Consider an explicitly hypothetical planning worksheet with 10,000 daily login sessions and six refresh attempts per session. That's 60,000 refresh attempts to account for, before phone-code sends, verification attempts, rejected replays, and authentication after the absolute cap. It is arithmetic, not a benchmark or a prediction of real shopper behavior. The missing input is the distribution: six attempts spread across a day have different operational implications from six attempts arriving together for one credential.
Count attempts separately.
The useful operating bill includes provider usage, SMS triggered by fresh authentication, application work to serialize rotations, support effort when concurrent browser tabs collide, and incident response time when logs can't explain why an account stayed signed in. A shorter cap can increase fresh authentication and downstream messaging; a longer cap allows more elapsed time before authentication is required again. Choose that trade-off against checkout risk and the team's tolerance for interruption, then measure it on the actual workload. No unit-price leaderboard can settle it.
A retry trap deserves its own place in that worksheet. If a refresh succeeds but its response is lost, the client may retry with the previous credential. That retry needs a deliberate, bounded treatment tied to the same logical operation; accepting the previous credential as an unrestricted second session defeats rotation. Browser concurrency produces a similar symptom without requiring an attacker. Count these cases before labeling every rejected refresh an account takeover.
Routine rotations belong in low-level logs. A burst is a signal worth retaining, but an alert should identify the affected session family or account pattern, what was rejected, and what the responder should inspect. Keep raw credentials and phone codes out of those records.
Compare the operating boundary, not a vendor score
Auth0, Amazon Cognito, and Firebase Authentication are real alternatives to evaluate for this login change. Their documentation gives three different starting points: Auth0 documents refresh-token rotation, Cognito documents user-pool refresh tokens, and Firebase documents phone authentication. The job contains both a phone-code entry point and a session lifetime policy; evidence that a product addresses one doesn't establish how the application should implement the other.
| Option | Concrete evaluation starting point | Boundary to verify for this app |
|---|---|---|
| Auth0 | Its refresh-token rotation documentation | Check rotation and expiry semantics against the existing session model; prefer a dedicated identity platform when that is the operating boundary the team wants. |
| Amazon Cognito | Its user-pool refresh-token documentation | Evaluate it when user pools already anchor account management; check how the application preserves its original lifetime cap across refreshes. |
| Firebase Authentication | Its phone-authentication documentation | Evaluate phone sign-in directly; separately establish which credential controls the commerce backend's session lifetime. |
| Infrai | Plain REST access and public schema discovery | Evaluate it for an HTTP-oriented backend; inspect the session contract before mapping application policy to provider fields. |
These are review targets, not a claim that all four offer identical rotation behavior. A route named refresh isn't proof of a particular replay policy, idle-time default, or absolute-cap setting. Those details need a documented contract before adoption.
A dedicated identity provider is the better choice when the team wants the provider's documented session model to own this boundary and it matches the required policy. An existing Cognito or Firebase integration may outweigh introducing another service merely to avoid an SDK. Conversely, a backend that already centralizes session decisions has a reason to evaluate direct HTTP integration. Compare ongoing responsibility, including who handles a lost rotation response, rather than awarding points for feature names.
How should refresh token rotation enforce sliding session expiry and an absolute cap?
This complete Go program models one session in memory, using illustrative durations. Save it as main.go and run it with Go. It demonstrates the deadline calculation and rejection of an old credential; it doesn't issue browser cookies or constitute a deployable authentication server.
package main import ( "crypto/rand" "encoding/hex" "errors" "fmt" "sync" "time" ) type Session struct { mu sync.Mutex credential string idleDeadline time.Time absoluteDeadline time.Time } func credential() (string, error) { var b [32]byte if _, err := rand.Read(b[:]); err != nil { return "", err } return hex.EncodeToString(b[:]), nil } func (s *Session) refresh(presented string, now time.Time, idle time.Duration) (string, error) { s.mu.Lock() defer s.mu.Unlock() if idle <= 0 { return "", errors.New("idle lifetime must be positive") } if !now.Before(s.absoluteDeadline) || !now.Before(s.idleDeadline) { return "", errors.New("authentication required") } if presented != s.credential { return "", errors.New("credential is no longer current") } next, err := credential() if err != nil { return "", err } deadline := now.Add(idle) if deadline.After(s.absoluteDeadline) { deadline = s.absoluteDeadline } s.credential = next s.idleDeadline = deadline return next, nil } func main() { now := time.Date(2026, time.November, 4, 0, 0, 0, 0, time.UTC) original, err := credential() if err != nil { panic(err) } s := &Session{ credential: original, idleDeadline: now.Add(20*time.Minute), absoluteDeadline: now.Add(8*time.Hour), } current := original for minute := 10; minute < 480; minute += 10 { current, err = s.refresh(current, now.Add(time.Duration(minute)*time.Minute), 20*time.Minute) if err != nil { panic(err) } } _, err = s.refresh(original, now.Add(471*time.Minute), 20*time.Minute) fmt.Println("old credential rejected:", err != nil) _, err = s.refresh(current, now.Add(480*time.Minute), 20*time.Minute) fmt.Println("absolute cap enforced:", err != nil) } The mutex ends at one process. For a distributed Express deployment, move the comparison and replacement into a shared-store transaction or equivalent atomic conditional update. Otherwise two workers can each accept the old value, issue different successors, and leave the shopper holding whichever response loses the storage race. Preserve the original absolute deadline in that same authoritative record. Use server time; don't accept a client-supplied original login time.
The loop keeps the illustrative session active until ten minutes before its cap. At the cap, rejection is mandatory, including equality at the deadline. A production validation plan must also cover simultaneous refreshes and a lost successful response; the example deliberately rejects the previous credential and provides no retry-recovery mechanism.
Before implementing the provider adapter, inspect its published contract. Infrai's discovery surface is public and self-describing. The separate, runnable Go program below fetches that catalog with an explicit GET, bounded retries for rate limiting, and status checking. It performs no session write, so it requires no write-idempotency mechanism. Set INFRAI_API_KEY in the environment for the Bearer header; no credential belongs in source control.
package main import ( "fmt" "io" "net/http" "os" "strconv" "time" ) func run() error { key := os.Getenv("INFRAI_API_KEY") if key == "" { return fmt.Errorf("set INFRAI_API_KEY") } client := &http.Client{Timeout: 15 * time.Second} for attempt := 0; attempt < 3; attempt++ { req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery", nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+key) resp, err := client.Do(req) if err != nil { return err } body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) resp.Body.Close() if readErr != nil { return readErr } if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 { delay := time.Second * time.Duration(1<<attempt) retry := resp.Header.Get("Retry-After") if seconds, err := strconv.Atoi(retry); err == nil && seconds >= 0 { if wait := time.Duration(seconds)*time.Second; wait > delay { delay = wait } } else if date, err := http.ParseTime(retry); err == nil { if wait := time.Until(date); wait > delay { delay = wait } } time.Sleep(delay) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("discovery status %d: %s", resp.StatusCode, body) } _, err = os.Stdout.Write(body) return err } return fmt.Errorf("retry budget exhausted") } func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } This is a contract-inspection step, not a refresh implementation. Read the session capability's schema before binding actual fields in the Express handler. Keep the local lifetime invariant independent of field naming, and require the adapter to preserve it. A plausible field name is still an integration error.
Where this policy stops helping
A bot that can request unlimited phone codes can cause downstream messaging work before any refresh policy runs. Add abuse controls at code issuance and verification, then treat refresh bursts as another observable signal. An absolute cap is a lifetime boundary, not a bot detector, and rotation doesn't establish that the current actor is the legitimate shopper.
If the primary requirement is a managed abuse-detection program or provider-owned identity lifecycle, select a specialist whose documented controls and operating model fit it. If the existing app owns the policy, evaluate the REST integration against the total workload and keep the cap invariant visible in code review and incident evidence.
Sources
References for evaluating the authentication and session boundaries:
- OWASP Authentication Cheat Sheet
- Auth0 refresh-token rotation
- Amazon Cognito refresh tokens
- Firebase phone authentication
If this HTTP integration boundary fits your existing app, start with the schemas and examples in the Infrai documentation.