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

A client logs in, receives token A and reuses it. An attacker who copies A gets the same 200 responses.

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

Short-lived access token A1 with refresh token R1. A stolen A1 works until it expires. A stolen R1 used after rotation is rejected and the session is revoked.

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:

  1. A token is valid for exactly one request.
  2. The server burns it the moment it is presented.
  3. The response carries the next token in an X-Next-Token header.
  4. Each token records its parent, so tokens form a chain: T1 → T2 → T3.
  5. Each token also expires on its own. Here it is a JWT that lives for 60 seconds.

The client sends T1 and receives T2, sends T2 and receives T3, sends T3 and receives T4.

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 client uses T2 and gets T3. The attacker replays T2, receives 401 and the chain is revoked.

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.

The attacker uses T3 first and gets T4. The client then sends T3, receives 401, and the chain is revoked. The attacker's T4 no longer works.

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.

How long a stolen token stays useful: hours to days for a single access token, minutes for access plus refresh, one request for a one-time token.

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(Source:https://dev.to/marius_trelea_44abd6fb263/one-time-access-tokens-what-if-every-api-request-burned-its-token-2knj

Related posts