DEV Community

Cover image for One-Time Access Tokens: What If Every API Request Burned Its Token?
Marius Trelea
Marius Trelea

Posted on

One-Time Access Tokens: What If Every API Request Burned Its Token?

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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) };
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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,
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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];
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 T3 and the response carrying T4 never arrives, the client's retry looks like an attack. The usual answer is a short grace window keyed on an Idempotency-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

Top comments (4)

Collapse
 
traceypears profile image
traceypears •

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:

  1. 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.

  2. 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.

  3. 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).

  4. 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.

  5. 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.