Session Revocation and Refresh Token Rotation: Where the Design Breaks
Short access tokens and rotating refresh tokens only protect you if rotation is atomic. A lab shows a read-then-write store missing about 72% of simultaneous token replays.
Enterprise customers eventually ask three questions about sessions. When a laptop is stolen, can we sign out just that device? When an employee leaves, how quickly does their access stop? When someone changes their password, why are old sessions still working? Each answer depends on how long your access tokens live, where refresh happens, and what you check on every request.
This guide explains a common design, short-lived access tokens with rotating refresh tokens, and the places where it is weaker than it looks. The lab sends the same refresh token twice at the same moment, which is what both a stolen-token replay and a browser with two open tabs look like, and compares a refresh store that reads then writes with one that checks and consumes atomically.
Why stateless tokens make revocation hard
A signed access token, such as a JWT defined in RFC 7519, can be verified without a database lookup. That is the point, and also the problem: if nothing is looked up, nothing can be revoked. The token is valid until its exp time, whatever happens in the meantime.
So the access-token lifetime is your worst-case revocation delay, unless you add a check. A seven-day access token means a stolen laptop has up to seven days of access. Five to fifteen minutes is common, but choose a number you are willing to state to customers as "access ends within X minutes".
Plain server-side sessions, where every request looks up a session ID, avoid this entirely and are often the simpler choice for a web application. Use signed tokens when you have a real reason, such as many services verifying requests independently.
The design: short access tokens, rotating refresh tokens
- Access token: short-lived, signed, carries the user, tenant, audience, and expiry. Verified locally by services.
- Refresh token: a long random string, sent only to the authentication service, stored server-side as a hash, and linked to a session family that represents one device or login.
- Rotation: each time a refresh token is used, it is marked consumed and a new one is issued in the same family.
- Reuse detection: if a consumed refresh token is presented again, either the legitimate client or a thief has an old copy. The server cannot tell which, so it revokes the whole family and requires sign-in.
The OAuth 2.0 Security Best Current Practice, RFC 9700, requires refresh tokens for public clients to be either sender-constrained or rotated in this way.
Lab: the same refresh token, twice, at the same time
Reuse detection depends entirely on the refresh store. The program runs 20,000 trials in which two goroutines present the same refresh token simultaneously.
- The two-step store reads the token, checks that it is unused, and then, in a separate step, marks it consumed and issues a new one. Each step takes the lock on its own, like a
GETand a laterSETagainst a remote store. Aruntime.Gosched()between them stands in for the network round trip. - The atomic store checks and consumes in one critical section, like a database transaction with a row lock, a Lua script, or a compare-and-swap.
A trial ends with "two live refresh tokens" if both requests succeeded and neither family was revoked. Run it with go run .; it uses only the standard library. Because it measures a real race, counts vary between runs and machines.
// Lab: two requests present the same refresh token at the same moment.
//
// This is what a stolen-token replay looks like, and also what a browser
// retry or two open tabs look like. We compare a store that reads the token
// and then writes the rotation (two steps) with one that does a single
// atomic check-and-consume. 20,000 trials per store.
package main
import (
"errors"
"fmt"
"runtime"
"sync"
)
var errReuse = errors.New("refresh token reuse")
type tok struct {
family int
consumed bool
revoked bool
}
type store struct {
mu sync.Mutex
tokens map[string]*tok
seq int
}
func (s *store) get(h string) *tok { s.mu.Lock(); defer s.mu.Unlock(); return s.tokens[h] }
func (s *store) issue(family int) string {
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
h := fmt.Sprintf("rt_%d", s.seq)
s.tokens[h] = &tok{family: family}
return h
}
func (s *store) revokeFamily(f int) {
s.mu.Lock()
defer s.mu.Unlock()
for _, t := range s.tokens {
if t.family == f {
t.revoked = true
}
}
}
// twoStep: read, check, then write. Each step is individually locked,
// like a GET followed by a SET against a remote store.
func (s *store) twoStep(h string) (string, error) {
t := s.get(h)
if t == nil || t.revoked {
return "", errors.New("invalid")
}
if t.consumed {
s.revokeFamily(t.family)
return "", errReuse
}
runtime.Gosched() // the network round trip between GET and SET
s.mu.Lock()
t.consumed = true
s.mu.Unlock()
return s.issue(t.family), nil
}
// atomic: check and consume in one critical section (a transaction,
// Lua script, or compare-and-swap in a real store).
func (s *store) atomic(h string) (string, error) {
s.mu.Lock()
t := s.tokens[h]
if t == nil || t.revoked {
s.mu.Unlock()
return "", errors.New("invalid")
}
if t.consumed {
s.mu.Unlock()
s.revokeFamily(t.family)
return "", errReuse
}
t.consumed = true
s.mu.Unlock()
return s.issue(t.family), nil
}
func trial(rotate func(*store, string) (string, error)) (bothLive bool, detected bool) {
s := &store{tokens: map[string]*tok{}}
h := s.issue(1)
var wg sync.WaitGroup
start := make(chan struct{})
results := make([]string, 2)
errs := make([]error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
results[i], errs[i] = rotate(s, h)
}(i)
}
close(start)
wg.Wait()
live := 0
for _, r := range results {
if r != "" && !s.tokens[r].revoked {
live++
}
}
return live == 2, errors.Is(errs[0], errReuse) || errors.Is(errs[1], errReuse)
}
func main() {
const trials = 20_000
for _, c := range []struct {
name string
fn func(*store, string) (string, error)
}{
{"read, then write (two steps)", (*store).twoStep},
{"atomic check-and-consume", (*store).atomic},
} {
forks, detected := 0, 0
for i := 0; i < trials; i++ {
b, d := trial(c.fn)
if b {
forks++
}
if d {
detected++
}
}
fmt.Printf("%-30s two live refresh tokens: %5d reuse detected: %5d (of %d trials)\n", c.name, forks, detected, trials)
}
}
Output recorded on November 3, 2026 (Go 1.26.5, windows/amd64, 16 logical CPUs). A second run gave 14,307 and 5,693 for the two-step store and the same result for the atomic store:
read, then write (two steps) two live refresh tokens: 14560 reuse detected: 5440 (of 20000 trials)
atomic check-and-consume two live refresh tokens: 0 reuse detected: 20000 (of 20000 trials)
What it shows
With the two-step store, about 72% of simultaneous presentations produced two valid refresh tokens in the same family and no reuse alert. If one of those requests was an attacker with a stolen token, the attacker now has a working session that rotation was supposed to catch. With the atomic store, every trial detected the reuse.
The race window in the lab is small: a goroutine yield, not a real network round trip. Against Redis or a database over the network, the window between read and write is much larger, so a non-atomic implementation will fail at least this often.
Legitimate concurrent refreshes
The same lab scenario also happens without any attacker: two browser tabs whose access tokens expire at the same moment, or a retry after a timeout. With strict reuse detection, those users get signed out. You have three options:
- Serialize refreshes in the client, so a browser only ever sends one refresh at a time (for example through a shared worker or a lock in local storage).
- Allow a short grace period in which the immediately previous token returns the same new token instead of triggering revocation. This is a deliberate, small replay window; keep it to seconds and log every use.
- Keep tokens out of the browser entirely with a backend-for-frontend that holds them server-side and gives the browser only a session cookie. The IETF draft on OAuth for browser-based applications describes this pattern and its alternatives.
Whichever you choose, the rotation itself must be atomic, or none of these options mean anything.
Revoking access before tokens expire
To end access sooner than the access-token lifetime, services must check something on each request. Two common designs:
- A "valid after" timestamp per user or per session family, kept in a fast store. When an administrator signs a user out, store the current time; reject any access token whose issue time (
iat) is earlier. Keep the marker for at least the access-token lifetime plus allowed clock skew. - A short denylist of revoked session IDs, with each entry kept until the tokens it covers would have expired anyway.
Both add a lookup to every request, which is what signed tokens were meant to avoid, so cache them briefly and decide what happens when the store is unreachable. For sensitive routes, fail closed; for low-risk reads, you may accept the remaining token lifetime. That decision belongs in your security documentation, not only in code.
A device list customers can use
To let users see and revoke individual devices, store a few fields with each session family: a client-generated device ID, a normalized browser and operating system string, the time of last refresh, and, if you can justify and disclose it, a coarse location. Revoking a device revokes its family; its access token stops working at expiry or at the next revocation check. Treat IP-based location as a hint, not evidence, because VPNs, mobile networks, and corporate proxies distort it.
The OWASP Session Management Cheat Sheet covers cookie attributes, session fixation, and idle and absolute timeouts, which apply to whichever session mechanism you choose.
Re-authentication for sensitive actions
Holding a valid session should not be enough to change the account's security settings. Require recent authentication, or a stronger factor, before adding an administrator, changing sign-in methods, exporting large amounts of data, or creating API credentials. Record when and how the user last authenticated, and check it on the server for those endpoints. NIST SP 800-63B describes reauthentication requirements by assurance level if your customers ask for a framework reference.
Where enterprise SSO fits
When users sign in through a customer's identity provider, ending their access at the identity provider does not end sessions you already issued. Your session lifetimes and revocation checks still decide how long access continues. The SSO guide covers how to connect identity-provider deprovisioning to your session store.
Where to start
Find the code that rotates refresh tokens and check whether "is this token unused?" and "mark it used" happen in one atomic operation. If they do not, fix that first; the lab shows how often the gap is hit. Then write down your access-token lifetime as a revocation promise, add a per-user "valid after" check for administrator sign-outs, and test concurrent refreshes from two tabs before customers do.
Frequently Asked Questions
Are server-side sessions less secure than JWTs?
No. A session ID looked up on every request is easier to revoke and to reason about. Signed tokens help when many services must verify requests without a shared session store; they do not add security by themselves.
How long should refresh tokens live?
Choose an idle timeout, after which an unused refresh token expires, and an absolute lifetime, after which the user must sign in again regardless. Many business applications use days to weeks for idle and weeks to months for absolute, adjusted to the data's sensitivity.
Should a password change sign out other devices?
Usually yes, by default, with the current device kept signed in. Set the user's valid-after time and revoke other session families. Some users change passwords precisely because they suspect another device is compromised.
Where should refresh tokens be stored in the browser?
Preferably not in JavaScript-readable storage. An HttpOnly, Secure, SameSite cookie scoped to the authentication endpoint, or a backend-for-frontend that keeps tokens server-side, limits what injected scripts can take.
What should happen when the revocation store is down?
Decide per route. Sensitive operations should fail closed. For ordinary reads you may accept the remaining access-token lifetime, but log that revocation checks were skipped so the risk window is visible.
Sources
Documents cited in this guide. Each link was opened and its title compared with the label below on the date shown. A source supports the specific statements it is linked from; design recommendations and lab results are Velynoriva's own.
-
RFC 9700: Best Current Practice for OAuth 2.0 Security
(Internet Engineering Task Force)
• Link checked 2026-11-03
Refresh token rotation and sender-constrained tokens for public clients.
-
OAuth 2.0 for Browser-Based Applications (Internet-Draft)
(IETF OAuth Working Group)
• Link checked 2026-11-03
Backend-for-frontend and other browser token patterns; work in progress.
-
RFC 7519: JSON Web Token (JWT)
(Internet Engineering Task Force)
• Link checked 2026-11-03
Token format and the exp and iat claims.
-
Session Management Cheat Sheet
(OWASP Foundation)
• Link checked 2026-11-03
Cookie attributes, timeouts, and session fixation.
-
NIST SP 800-63B-4: Authentication and Authenticator Management
(National Institute of Standards and Technology)
• Link checked 2026-11-03
Reauthentication requirements by assurance level.
Revision history
- — Added a reproducible lab measuring refresh-token rotation races with non-atomic and atomic stores, explained legitimate concurrent refreshes and the backend-for-frontend option, and removed the example device table and fixed browser versions.
- — Replaced a non-atomic rotation example with an atomic store contract and updated to RFC 9700 and NIST SP 800-63B-4.