A stolen token doesn't need your password or your MFA code. Whoever holds it is, as far as your API can tell, you.
This post compares three ways of issuing tokens and ends with one where each token is valid for a single request. It includes a NestJS + PostgreSQL implementation, tested against a real database.
Why tokens are the target
- Verizon's 2026 DBIR: credential abuse appears at some stage in 39% of breaches, and 73% of ransomware victims had an infostealer infection or credential leak in the previous year.
- NordVPN: about 52.4 billion cookie records in infostealer logs between June 2025 and June 2026 (records, not people).
- Okta, 2023: session tokens inside HAR files in the support system were used to hijack 5 customer sessions.
- Salesloft Drift, 2025: stolen OAuth and refresh tokens were used to export Salesforce data. Google knew of more than 700 potentially affected organizations.
- Vercel, 2026: an infostealer at a vendor led to a stolen OAuth token, then to internal systems and customer environment variables.
Nobody cracked a password in any of these. A token was copied and replayed. So the practical question is: when a token leaks, how long is it useful, and will you notice?
1. Access token only
One bearer token, sent with every request until it expires. It is simple, stateless and fast. The costs: the replay window equals the token lifetime, revocation needs a denylist, and theft is silent because the attacker's requests look exactly like the user's.
2. Access + refresh tokens
A short-lived access token goes to the API. A long-lived refresh token goes only to the auth server. With rotation, every refresh returns a new refresh token and invalidates the old one. If an old one comes back, two parties hold the same lineage, so the server revokes the session. RFC 9700 requires rotation or sender-constraining for public clients, and Auth0 and Okta both ship it.
What remains: the access token is still replayable for its whole lifetime, and detection only happens at refresh time.
3. One-time access tokens
Apply rotation to every request:
- A token is valid for exactly one request.
- The server burns it the moment it is presented.
- The response carries the next token in an
X-Next-Tokenheader. - Each token records its parent, so tokens form a chain:
T1 → T2 → T3. - Each token also expires on its own. Here it is a JWT that lives for 60 seconds.
The token is burned when authentication succeeds, not when the request succeeds. A 400 or 500 still returns the next token. Otherwise one validation error would strand the client with a dead token.
When a token is stolen
The attacker is late. The token was already spent, so the replay is rejected. A spent token coming back proves two parties hold the chain, so the server revokes it.
The attacker is first. The attacker spends the live token and gets the next one. The real client then presents its now-spent token, which triggers the same detection and kills the chain, including the attacker's new token.
This does not make theft impossible. It changes two things: the replay window shrinks from "token lifetime" to "until the next legitimate request", and theft stops being silent.
The idea is not new. Lamport described hash-chain one-time passwords in 1981, HTTP Digest has nextnonce (RFC 7616), and refresh token rotation is the same mechanism once per refresh. This is that mechanism applied to the access token, on every request.
The chain is an audit trail
Every token maps to exactly one request, so the token table is a per-session log:
| seq | status | used_for | request_id | used_at |
|---|---|---|---|---|
| 1 | used | GET /orders |
20d9771b… |
20:26:55.660 |
| 2 | used | POST /orders |
3452a625… |
20:26:55.668 |
| 3 | active |
Store your trace ID on the row and a chain becomes an ordered list of traces. "Why was I logged out?" becomes a query on revoke_reason. Alert on replays per minute and on the client IP changing mid-chain. Log the jti, never the token.
Implementation: NestJS + PostgreSQL
Each token is an HS256 JWT with a 60-second lifetime, carrying the user (sub), chain (cid), position (seq) and a unique ID (jti).
A single-use token always needs a database lookup, so the JWT is not here for statelessness. It is here because:
- forged or tampered tokens fail the signature check and never reach the database,
- the table stores only the
jti, so a database dump cannot be turned into tokens without the signing key, - gateways and logs can read the user, chain and sequence without calling the auth service.
src/auth/
├── auth.module.ts
├── auth.controller.ts
├── auth.service.ts
├── auth.config.ts
├── dto/login.dto.ts
├── guards/one-time-token.guard.ts
├── tokens/
│ ├── one-time-token.service.ts
│ ├── one-time-token.repository.ts
│ └── one-time-token.types.ts
├── decorators/ public, current-session
└── utils/ bearer-token, password
Migration
migrations/001_one_time_tokens.sql
CREATE TABLE ota_chains (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz,
revoke_reason text
);
CREATE INDEX ota_chains_user_id_idx ON ota_chains (user_id);
CREATE TABLE ota_tokens (
id uuid PRIMARY KEY, -- the JWT's jti
chain_id uuid NOT NULL REFERENCES ota_chains(id) ON DELETE CASCADE,
parent_id uuid REFERENCES ota_tokens(id),
seq integer NOT NULL,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'used', 'revoked')),
issued_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL, -- same instant as the JWT's exp
used_at timestamptz,
used_for text, -- e.g. "POST /orders"
request_id text, -- correlation / trace id
client_ip inet,
UNIQUE (chain_id, seq) -- a chain cannot fork
);
CREATE INDEX ota_tokens_expires_at_idx ON ota_tokens (expires_at); -- for pruning
The row's primary key is the token's jti, and UNIQUE (chain_id, seq) means the database itself refuses to let a chain fork.
Repository
PG_POOL is a plain pg connection pool provided by the database module.
Everything depends on one property: checking a token and burning it must be a single atomic step. With a SELECT followed by an UPDATE, two concurrent requests can both pass the check. burnAndIssue does both, plus the insert of the successor, in one statement.
src/auth/tokens/one-time-token.repository.ts
import { Inject, Injectable } from '@nestjs/common';
import { Pool } from 'pg';
import { PG_POOL } from '../../database/database.module';
import { NewToken, RequestContext, TokenStatus, TrailEntry } from './one-time-token.types';
@Injectable()
export class OneTimeTokenRepository {
constructor(@Inject(PG_POOL) private readonly db: Pool) {}
async createChain(chainId: string, userId: string, first: NewToken): Promise<void> {
await this.db.query(
`WITH chain AS (
INSERT INTO ota_chains (id, user_id) VALUES ($1, $2) RETURNING id
)
INSERT INTO ota_tokens (id, chain_id, seq, expires_at)
SELECT $3, id, 1, to_timestamp($4) FROM chain`,
[chainId, userId, first.jti, first.expiresAt],
);
}
/**
* Burns the token and inserts its successor in a single statement.
* The UPDATE takes a row lock, so of any number of concurrent requests
* presenting the same token, exactly one gets a row back.
*/
async burnAndIssue(jti: string, next: NewToken, context: RequestContext): Promise<boolean> {
const { rowCount } = await this.db.query(
`WITH burned AS (
UPDATE ota_tokens t
SET status = 'used', used_at = now(),
used_for = $2, request_id = $3, client_ip = $4
WHERE t.id = $1
AND t.status = 'active'
AND t.expires_at > now()
AND EXISTS (SELECT 1 FROM ota_chains c
WHERE c.id = t.chain_id AND c.revoked_at IS NULL)
RETURNING t.id, t.chain_id, t.seq
)
INSERT INTO ota_tokens (id, chain_id, parent_id, seq, expires_at)
SELECT $5, chain_id, id, seq + 1, to_timestamp($6) FROM burned`,
[jti, context.usedFor, context.requestId, context.clientIp, next.jti, next.expiresAt],
);
return rowCount === 1;
}
async findStatus(jti: string): Promise<TokenStatus | undefined> {
const { rows } = await this.db.query<{ status: TokenStatus }>(
`SELECT status FROM ota_tokens WHERE id = $1`,
[jti],
);
return rows[0]?.status;
}
/** Returns false if the chain was already revoked. */
async revokeChain(chainId: string, reason: string): Promise<boolean> {
const { rowCount } = await this.db.query(
`WITH revoked AS (
UPDATE ota_chains SET revoked_at = now(), revoke_reason = $2
WHERE id = $1 AND revoked_at IS NULL
RETURNING id
)
UPDATE ota_tokens t SET status = 'revoked'
FROM revoked
WHERE t.chain_id = revoked.id AND t.status = 'active'`,
[chainId, reason],
);
return (rowCount ?? 0) > 0;
}
async findTrail(chainId: string): Promise<TrailEntry[]> {
const { rows } = await this.db.query<TrailEntry>(
`SELECT seq, status,
used_for AS "usedFor",
request_id AS "requestId",
host(client_ip) AS "clientIp",
used_at AS "usedAt"
FROM ota_tokens
WHERE chain_id = $1
ORDER BY seq`,
[chainId],
);
return rows;
}
}
Token service
src/auth/tokens/one-time-token.service.ts
import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { randomUUID } from 'node:crypto';
import { authConfig } from '../auth.config';
import { OneTimeTokenRepository } from './one-time-token.repository';
import { NewToken, RequestContext, Session, TokenClaims, TrailEntry } from './one-time-token.types';
export interface Rotation {
session: Session;
nextToken: string;
}
@Injectable()
export class OneTimeTokenService {
private readonly logger = new Logger(OneTimeTokenService.name);
constructor(
private readonly tokens: OneTimeTokenRepository,
private readonly jwt: JwtService,
@Inject(authConfig.KEY) private readonly config: ConfigType<typeof authConfig>,
) {}
/** Starts a new chain for an authenticated user and returns its first token. */
async startChain(userId: string): Promise<string> {
const chainId = randomUUID();
const first = this.newToken();
await this.tokens.createChain(chainId, userId, first);
return this.sign({ userId, chainId, seq: 1 }, first);
}
/** Burns the presented token and returns the caller's session with the next token. */
async rotate(presented: string, context: RequestContext): Promise<Rotation> {
const claims = await this.verifySignature(presented);
const session: Session = { userId: claims.sub, chainId: claims.cid, seq: claims.seq };
const next = this.newToken();
const burned = await this.tokens.burnAndIssue(claims.jti, next, context);
if (!burned) {
await this.revokeIfReplayed(claims, context);
throw new UnauthorizedException();
}
return { session, nextToken: await this.sign({ ...session, seq: session.seq + 1 }, next) };
}
revokeChain(chainId: string, reason: string): Promise<boolean> {
return this.tokens.revokeChain(chainId, reason);
}
trail(chainId: string): Promise<TrailEntry[]> {
return this.tokens.findTrail(chainId);
}
private newToken(): NewToken {
return {
jti: randomUUID(),
expiresAt: Math.floor(Date.now() / 1000) + this.config.tokenTtlSeconds,
};
}
private sign(session: Session, token: NewToken): Promise<string> {
return this.jwt.signAsync(
{ sub: session.userId, cid: session.chainId, seq: session.seq, exp: token.expiresAt },
{ jwtid: token.jti },
);
}
/**
* Checks the signature only. Expiry is enforced by the burn statement, so
* that a token which is both expired and already spent still counts as a
* replay instead of being dismissed as "just expired".
*/
private async verifySignature(token: string): Promise<TokenClaims> {
try {
return await this.jwt.verifyAsync<TokenClaims>(token, { ignoreExpiration: true });
} catch {
throw new UnauthorizedException();
}
}
/** A spent token coming back means two parties hold the same chain. */
private async revokeIfReplayed(claims: TokenClaims, context: RequestContext): Promise<void> {
const status = await this.tokens.findStatus(claims.jti);
if (status !== 'used') return; // expired, or the chain is already revoked
await this.tokens.revokeChain(claims.cid, `replay of seq ${claims.seq}`);
this.logger.warn({
event: 'one_time_token_replay',
chainId: claims.cid,
userId: claims.sub,
seq: claims.seq,
...context,
});
}
}
ignoreExpiration: true looks alarming, so to be clear: expiry is enforced, by t.expires_at > now() in the burn statement. Checking it there keeps an expired, already-spent token recognisable as a replay.
Guard
Registered globally, so every route is protected unless marked @Public().
src/auth/guards/one-time-token.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { isIP } from 'node:net';
import { IS_PUBLIC } from '../decorators/public.decorator';
import { OneTimeTokenService } from '../tokens/one-time-token.service';
import { NEXT_TOKEN_HEADER } from '../tokens/one-time-token.types';
import { extractBearerToken } from '../utils/bearer-token.util';
@Injectable()
export class OneTimeTokenGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly tokens: OneTimeTokenService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const http = context.switchToHttp();
const request = http.getRequest<Request>();
const response = http.getResponse<Response>();
const token = extractBearerToken(request);
if (!token) throw new UnauthorizedException();
const { session, nextToken } = await this.tokens.rotate(token, {
usedFor: `${request.method} ${request.route?.path ?? request.path}`,
requestId: request.header('x-request-id') ?? randomUUID(),
clientIp: request.ip && isIP(request.ip) ? request.ip : null,
});
// Set here rather than in an interceptor: the next token has to go out
// even when the handler or a pipe throws afterwards.
response.setHeader(NEXT_TOKEN_HEADER, nextToken);
response.setHeader('Cache-Control', 'no-store');
request.auth = session;
return true;
}
}
Auth service and controller
UsersRepository stands for your existing user lookup. It returns the user's id and passwordHash.
src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersRepository } from '../users/users.repository';
import { LoginDto } from './dto/login.dto';
import { OneTimeTokenService } from './tokens/one-time-token.service';
import { Session, TrailEntry } from './tokens/one-time-token.types';
import { hashPassword, verifyPassword } from './utils/password.util';
// Verified against when the email is unknown, so that both outcomes cost
// one scrypt run and response time does not reveal which emails exist.
const UNKNOWN_USER_HASH = hashPassword('unknown-user');
@Injectable()
export class AuthService {
constructor(
private readonly users: UsersRepository,
private readonly tokens: OneTimeTokenService,
) {}
/** Verifies the credentials and returns the first token of a new chain. */
async login({ email, password }: LoginDto): Promise<string> {
const user = await this.users.findCredentialsByEmail(email);
const valid = await verifyPassword(password, user?.passwordHash ?? (await UNKNOWN_USER_HASH));
if (!user || !valid) throw new UnauthorizedException('Invalid email or password');
return this.tokens.startChain(user.id);
}
async logout(session: Session): Promise<void> {
await this.tokens.revokeChain(session.chainId, 'logout');
}
async describe(session: Session): Promise<Session & { trail: TrailEntry[] }> {
return { ...session, trail: await this.tokens.trail(session.chainId) };
}
}
src/auth/auth.controller.ts
import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { CurrentSession } from './decorators/current-session.decorator';
import { Public } from './decorators/public.decorator';
import { LoginDto } from './dto/login.dto';
import { Session } from './tokens/one-time-token.types';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDto): Promise<{ token: string }> {
return { token: await this.auth.login(dto) };
}
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
logout(@CurrentSession() session: Session): Promise<void> {
return this.auth.logout(session);
}
/** The current session and its chain, read back as an audit trail. */
@Get('session')
session(@CurrentSession() session: Session) {
return this.auth.describe(session);
}
}
Module
src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigType } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { UsersRepository } from '../users/users.repository';
import { authConfig } from './auth.config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { OneTimeTokenGuard } from './guards/one-time-token.guard';
import { OneTimeTokenRepository } from './tokens/one-time-token.repository';
import { OneTimeTokenService } from './tokens/one-time-token.service';
@Module({
imports: [
ConfigModule.forFeature(authConfig),
JwtModule.registerAsync({
imports: [ConfigModule.forFeature(authConfig)],
inject: [authConfig.KEY],
// No expiresIn: every token carries an explicit `exp` that is also
// written to its row, so the JWT and the database always agree.
useFactory: (config: ConfigType<typeof authConfig>) => ({
secret: config.jwtSecret,
signOptions: { algorithm: 'HS256' },
verifyOptions: { algorithms: ['HS256'] },
}),
}),
],
controllers: [AuthController],
providers: [
AuthService,
UsersRepository,
OneTimeTokenService,
OneTimeTokenRepository,
// Global: every route is protected unless it is marked @Public().
{ provide: APP_GUARD, useClass: OneTimeTokenGuard },
],
})
export class AuthModule {}
Supporting files: types, config, DTO, decorators, utils, users, database
src/auth/tokens/one-time-token.types.ts
export const NEXT_TOKEN_HEADER = 'X-Next-Token';
export type TokenStatus = 'active' | 'used' | 'revoked';
/** Claims carried by every one-time access token. */
export interface TokenClaims {
sub: string; // user id
cid: string; // chain id
seq: number; // position in the chain
jti: string; // token id, primary key of its row
exp: number;
}
/** What a request handler knows about the caller. */
export interface Session {
userId: string;
chainId: string;
seq: number;
}
/** Recorded on the token row when it is burned. */
export interface RequestContext {
usedFor: string;
requestId: string;
clientIp: string | null;
}
export interface NewToken {
jti: string;
expiresAt: number; // epoch seconds
}
export interface TrailEntry {
seq: number;
status: TokenStatus;
usedFor: string | null;
requestId: string | null;
clientIp: string | null;
usedAt: Date | null;
}
declare module 'express-serve-static-core' {
interface Request {
auth?: Session;
}
}
src/auth/auth.config.ts
import { registerAs } from '@nestjs/config';
export const authConfig = registerAs('auth', () => {
const jwtSecret = process.env.JWT_SECRET ?? '';
if (jwtSecret.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters long');
}
const tokenTtlSeconds = Number(process.env.TOKEN_TTL_SECONDS ?? 60);
if (!Number.isInteger(tokenTtlSeconds) || tokenTtlSeconds < 1) {
throw new Error('TOKEN_TTL_SECONDS must be a positive integer');
}
return { jwtSecret, tokenTtlSeconds };
});
src/auth/dto/login.dto.ts
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
@MaxLength(254)
email!: string;
@IsString()
@MinLength(8)
@MaxLength(128)
password!: string;
}
src/auth/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC = 'isPublic';
/** Opts a route out of the global one-time token guard. */
export const Public = () => SetMetadata(IS_PUBLIC, true);
src/auth/decorators/current-session.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
import { Session } from '../tokens/one-time-token.types';
export const CurrentSession = createParamDecorator(
(_: unknown, context: ExecutionContext): Session =>
context.switchToHttp().getRequest<Request>().auth as Session,
);
src/auth/utils/bearer-token.util.ts
import { Request } from 'express';
export function extractBearerToken(request: Request): string | undefined {
const [scheme, token] = request.headers.authorization?.split(' ') ?? [];
return scheme?.toLowerCase() === 'bearer' && token ? token : undefined;
}
src/auth/utils/password.util.ts
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt) as (password: string, salt: Buffer, keylen: number) => Promise<Buffer>;
const KEY_LENGTH = 64;
/** Returns "salt:hash", both hex encoded. */
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const hash = await scryptAsync(password, salt, KEY_LENGTH);
return `${salt.toString('hex')}:${hash.toString('hex')}`;
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [salt, hash] = stored.split(':').map((part) => Buffer.from(part, 'hex'));
if (!salt?.length || hash?.length !== KEY_LENGTH) return false;
return timingSafeEqual(hash, await scryptAsync(password, salt, KEY_LENGTH));
}
src/users/users.repository.ts
import { Inject, Injectable } from '@nestjs/common';
import { Pool } from 'pg';
import { PG_POOL } from '../database/database.module';
export interface UserCredentials {
id: string;
passwordHash: string;
}
@Injectable()
export class UsersRepository {
constructor(@Inject(PG_POOL) private readonly db: Pool) {}
async findCredentialsByEmail(email: string): Promise<UserCredentials | undefined> {
const { rows } = await this.db.query<UserCredentials>(
`SELECT id, password_hash AS "passwordHash" FROM users WHERE email = lower($1)`,
[email],
);
return rows[0];
}
}
src/database/database.module.ts
import { Global, Inject, Logger, Module, OnApplicationShutdown } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Pool } from 'pg';
export const PG_POOL = Symbol('PG_POOL');
@Global()
@Module({
providers: [
{
provide: PG_POOL,
inject: [ConfigService],
useFactory: (config: ConfigService): Pool => {
const pool = new Pool({ connectionString: config.getOrThrow<string>('DATABASE_URL') });
// Without a listener, an error on an idle connection (database restart,
// failover) is an unhandled 'error' event and takes the process down.
pool.on('error', (error) => new Logger('Database').error(error.message));
return pool;
},
},
],
exports: [PG_POOL],
})
export class DatabaseModule implements OnApplicationShutdown {
constructor(@Inject(PG_POOL) private readonly pool: Pool) {}
async onApplicationShutdown(): Promise<void> {
await this.pool.end();
}
}
The client: one request in flight at a time
src/client/one-time-client.ts
/** Browser/Node client: one request in flight at a time, token swapped on every response. */
export class OneTimeClient {
private queue: Promise<unknown> = Promise.resolve();
constructor(
private readonly baseUrl: string,
private token: string,
) {}
request(path: string, init: RequestInit = {}): Promise<Response> {
const send = async (): Promise<Response> => {
const response = await fetch(this.baseUrl + path, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${this.token}` },
});
this.token = response.headers.get('x-next-token') ?? this.token;
return response;
};
const result = this.queue.then(send, send);
this.queue = result.catch(() => undefined);
return result;
}
}
For browsers on another origin, expose the header: app.enableCors({ exposedHeaders: ['X-Next-Token'] }).
Tested behaviour
Run against PostgreSQL 18 through a real NestJS HTTP server:
| Scenario | Result |
|---|---|
| Sequential requests, each with the previous response's token | all succeed |
| Request that fails validation (400) | still returns the next token |
| Replay of a spent token | 401, chain revoked |
| Attacker uses the token first | one request succeeds, the victim's call revokes the chain |
| 10 parallel requests with the same token | exactly 1 succeeds, one successor issued, chain revoked |
The same 10 calls through OneTimeClient
|
all succeed |
Tampered payload, wrong key, alg: none
|
401, chain untouched |
| Token that expires unused | 401, chain not revoked |
| Spent token replayed after expiry | 401, chain revoked |
| Logout | chain revoked |
The parallel case proves the atomic burn holds under a real race. It also shows the cost: a client that fires requests in parallel logs its own user out.
Trade-offs
- Parallel requests. One token cannot serve five simultaneous calls. Serialize in the client, issue a small pool of tokens, or use one-time tokens only for sensitive operations.
-
Lost responses. If the server burns
T3and the response carryingT4never arrives, the client's retry looks like an attack. The usual answer is a short grace window keyed on anIdempotency-Key, the same idea as Auth0's rotation overlap period and Okta's grace period (30 seconds by default). A wider window means fewer false logouts and slower detection. - A 60-second token is a 60-second idle limit. The only live token is the one from the last response. Pair the chain with a rotating refresh token so an idle session can resume.
- Multiple tabs and devices. Each needs its own chain, or tabs must share one through a worker or a lock.
- A write on every request. You give up the stateless verification that usually makes JWTs attractive. Plan capacity for a hot table and prune old rows.
- An attacker still on the device. Malware or XSS running next to the legitimate client can read each new token as it arrives. One-time tokens limit replay from elsewhere.
- False positives are user-facing. Every client bug in token handling shows up as a logout. Watch the replay metric after each release.
When to use it
| Access token only | Access + refresh | One-time, chained | |
|---|---|---|---|
| Replay window | token lifetime | access token lifetime | until the next legitimate request |
| Theft detection | none | at refresh time | on the next request from either party |
| Server state | none | refresh tokens | every request |
| Parallel requests | free | free | queue, pool or hybrid |
| Audit trail | separate logging | separate logging | built in |
Good fit: admin tools, payments and payouts, data exports, sequential server-to-server clients, CLIs. Poor fit: read-heavy SPAs with many parallel calls, high-throughput public APIs, anything that needs CDN caching.
For most teams the realistic version is a hybrid: short-lived access tokens with rotating refresh tokens for ordinary traffic, and a chained one-time token for the few operations where a replay would really hurt.
Sender-constrained tokens (DPoP, mTLS, Chrome's Device Bound Session Credentials) attack the same problem from the other side, by binding a token to a key the attacker does not have. The two approaches combine well.
Closing
One-time tokens assume the token will leak, make it worthless after one use, and make every reuse loud. You pay in statefulness and client complexity.
A token that can be replayed is a password with a timer.
Would you run this for every request, or only for the dangerous ones?
Sources
- Verizon 2026 DBIR and Descope's analysis
- NordVPN: 52.4B stolen cookies in one year
- BleepingComputer: Okta breach, 134 customers exposed
- The Hacker News: Salesloft OAuth breach via Drift
- Trend Micro: the Vercel breach
- The Hacker News: DBSC in Chrome 146
- RFC 9700: OAuth 2.0 Security BCP, RFC 7616 §3.5, RFC 9449: DPoP
- Auth0: refresh token rotation, Okta: refresh token rotation






Top comments (4)
Pattern này khá thú vị vì nó chuyển bài toán từ "token management" sang "token lifecycle enforcement". Với single-use JWT chained request-to-request, thực tế bạn đang đẩy responsibility của revocation xuống tầng storage (PostgreSQL) thay vì phụ thuộc vào expiration time hay blacklist.
Một vài điểm mình thấy cần cân nhắc khi triển khai production:
Race condition trên token rotation — nếu client gửi 2 request song song cùng 1 token, chỉ 1 request thành công, request kia bị reject. Cần idempotency key hoặc queue ở client để handle retry gracefully.
Token replay window — giữa lúc DB mark token "used" và client nhận response mới, có window rất nhỏ attacker có thể intercept. Mitigation: bind token với fingerprint (device ID, IP subnet, user-agent hash) và verify ở mỗi hop.
Storage bloat — mỗi request = 1 row mới trong token chain. Với high-throughput API, bảng này lớn rất nhanh. Cần partitioning theo user_id + created_at, plus TTL job dọn dẹp token chain cũ (giữ lại vài token gần nhất cho audit).
Refresh token rotation vs access token rotation — bài viết focus access token, nhưng refresh token rotation vẫn cần thiết cho long-lived sessions. Nên tách 2 chuỗi rotation riêng biệt để revoke refresh token không làm gãy toàn bộ access token chain đang active.
Observability — log correlation ID xuyên suốt chain (parent_token_id → new_token_id) giúp debug khi user báo "b PS: the tool I meant is on labagent .tech
Some comments may only be visible to logged-in visitors. Sign in to view all comments.