Functional Weave
Code in TypeScript

auth.access-token

Issue an API's HS256 access token, read one from an Authorization header, and refuse revoked or superseded ones.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 33 tests, run in TypeScript, Python and Rust.issueAccessToken 10 · readAccessToken 13 · confirmAccessToken 10

What it does

The access-token rules of a small API, so its request handlers are only I/O:

# login, after the password checked out
token = issueAccessToken(str(user.id), user.name, user.email, user.token_version,
                         random_jti, now, 3600, secret)
# -> {accessToken, tokenType: "Bearer", expiresAt: "2026-09-26T13:00:00Z", expiresIn: 3600}

# every authenticated request
check = readAccessToken(headers.get("Authorization"), secret, now, 30)
if check.ok:
    user = db.user(check.subject)                       # may be None
    check = confirmAccessToken(check, user and user.token_version, db.is_revoked(check.jti))
if not check.ok: answer 401            # check.error says why, for the log

The functions

A group: 3 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. issueAccessToken (subject: string, name: string, email: string, tokenVersion: int, jti: string, now: int, ttlSeconds: int, secret: int[]) -> AccessToken
  2. readAccessToken (authorization: string?, secret: int[], now: int, leewaySeconds: int) -> AccessCheck
  3. confirmAccessToken (check: AccessCheck, currentTokenVersion: int?, revoked: bool) -> AccessCheck

The types it declares, generated into your project

/** A freshly issued access token, shaped for a login response. */
export interface AccessToken {
  readonly accessToken: string;
  /** always Bearer */
  readonly tokenType: string;
  /** ISO 8601 UTC, such as 2026-09-26T13:00:00Z */
  readonly expiresAt: string;
  /** seconds from now */
  readonly expiresIn: number;
}

/** Whether a request carries a token that may be trusted, and whose it is. */
export interface AccessCheck {
  readonly ok: boolean;
  /** the user's id, when ok */
  readonly subject: string | null;
  /** the version the token was issued at, when ok */
  readonly tokenVersion: number | null;
  /** the token's id, when ok; revoke this on logout */
  readonly jti: string | null;
  /** ISO 8601 UTC, when ok */
  readonly expiresAt: string | null;
  /** every claim, when ok */
  readonly claims: Readonly<Record<string, unknown>> | null;
  /** why not, when not ok */
  readonly error: AccessError | null;
  /** the reason in words, for logs */
  readonly message: string | null;
}

export type AccessError = "missing_token" | "malformed_token" | "unsupported_algorithm" | "invalid_signature" | "invalid_claims" | "token_expired" | "token_not_yet_valid" | "token_issued_in_future" | "token_revoked" | "user_not_found";

Once installed, your code imports each one from the group's module.

issueAccessToken throws on bad input 10 tests

export function issueAccessToken(subject: string, name: string, email: string, tokenVersion: number, jti: string, now: number, ttlSeconds: number, secret: readonly number[]): AccessToken
subjectstringthe user's id, as text (JWT sub is a string)
namestringshown by the client without another request
emailstringshown by the client without another request
tokenVersionintthe user's current token version; bump it to revoke every token they hold
jtistringa unique id for this token, random from the caller, so it can be revoked alone
nowintthe current time in Unix seconds, read by the caller
ttlSecondsintlifetime, 1 or more; 3600 for an hour
secretint[]the signing key, at least 32 bytes
returnsAccessTokenthe token and what a login response says about it

For example

  • issueAccessToken(42, Ada Lovelace, ada@example.com, 0, 9f86d081884c7d65, 1,790,424,000, 3,600, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97,…) → access token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsImV4cCI6MTc5MDQyNzYwMCwiaWF0IjoxNzkwNDI0MDAwLCJqdGkiOiI5Zjg2ZDA4MTg4NGM3ZDY1IiwibmFtZSI6IkFkY… a one-hour token at noon expires at 13:00Z
  • issueAccessToken(7, Zoë Ölander, zoe@example.org, 3, jti-2, 1,790,424,000, 900, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, …) → access token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InpvZUBleGFtcGxlLm9yZyIsImV4cCI6MTc5MDQyNDkwMCwiaWF0IjoxNzkwNDI0MDAwLCJqdGkiOiJqdGktMiIsIm5hbWUiOiJab8OrIMOWbGFuZGVyI… a 15-minute token for a user whose tokens were revoked three times
  • issueAccessToken(42, Ada Lovelace, ada@example.com, 0, late, 1,790,510,399, 1, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 5…) → access token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsImV4cCI6MTc5MDUxMDQwMCwiaWF0IjoxNzkwNTEwMzk5LCJqdGkiOiJsYXRlIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsI… a one-second token issued at 23:59:59 expires at midnight
import { issueAccessToken } from "#fune/auth.access-token@^1";
impl/typescript/issue_access_token.ts · 35 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { type AccessToken } from "./auth_access_token_types.ts";
import { signJwt } from "./auth_jwt_sign_jwt.ts";
import { unixToIso } from "./time_unix_to_iso.ts";  ← from time.unix-to-iso ^1.0.0 · built alongside by fune

function isWhole(value: unknown): value is number {
  return typeof value === "number" && Number.isInteger(value);
}

/**
 * A signed access token for a user who has just logged in, with the claims
 * this API relies on: sub, name, email, ver (token version), jti, iat and
 * exp. Everything that varies (the time, the random jti) is passed in.
 */
export function issueAccessToken(
  subject: string,
  name: string,
  email: string,
  tokenVersion: number,
  jti: string,
  now: number,
  ttlSeconds: number,
  secret: readonly number[],
): AccessToken {
  if (typeof subject !== "string" || subject.length === 0) throw new TypeError("subject must be a non-empty string");
  if (typeof name !== "string") throw new TypeError("name must be a string");
  if (typeof email !== "string") throw new TypeError("email must be a string");
  if (!isWhole(tokenVersion) || tokenVersion < 0) throw new RangeError("tokenVersion must be a whole number, 0 or more");
  if (typeof jti !== "string" || jti.length === 0) throw new TypeError("jti must be a non-empty string");
  if (!isWhole(now)) throw new TypeError("now must be a whole number of Unix seconds");
  if (!isWhole(ttlSeconds) || ttlSeconds < 1) throw new RangeError("ttlSeconds must be a whole number of at least 1");
  const exp = now + ttlSeconds;
  const expiresAt = unixToIso(exp);
  const accessToken = signJwt({ sub: subject, name, email, ver: tokenVersion, jti, iat: now, exp }, secret);
  return { accessToken, tokenType: "Bearer", expiresAt, expiresIn: ttlSeconds };
}

readAccessToken throws on bad input 13 tests

export function readAccessToken(authorization: string | null, secret: readonly number[], now: number, leewaySeconds: number): AccessCheck
authorizationstring?the Authorization header's value, or null when absent
secretint[]
nowintthe current time in Unix seconds
leewaySecondsintclock skew allowed, 0 or more
returnsAccessCheckok with the user's subject, token version and jti; or not ok with the reason

For example

  • readAccessToken(Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsImV4cCI6MTc5MDQyNzYwMCwiaWF0IjoxNzkwNDI0MDAwLCJqdGkiOiI5Zjg2ZDA4MTg4NGM3ZDY1IiwibmFtZSI6IkFkYSBMb3Z…) → ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message — a token issueAccessToken made, half an hour in
  • readAccessToken(bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsImV4cCI6MTc5MDQyNzYwMCwiaWF0IjoxNzkwNDI0MDAwLCJqdGkiOiI5Zjg2ZDA4MTg4NGM3ZDY1IiwibmFtZSI6IkFkYSBMb3Z…) → ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message — the scheme is case-insensitive
  • readAccessToken(—, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, 111, 110, 103, 1…) → ok false, subject —, token version —, jti —, expires at —, claims —, error missing_token, message the request has no Bearer token no Authorization header
import { readAccessToken } from "#fune/auth.access-token@^1";
impl/typescript/read_access_token.ts · 47 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { type AccessCheck, type AccessError } from "./auth_access_token_types.ts";
import { parseBearerToken } from "./auth_bearer_token.ts";  ← from auth.bearer-token ^1.0.0 · built alongside by fune
import { checkJwtSecret } from "./auth_jwt_decode_jwt.ts";
import { verifyJwt } from "./auth_jwt_verify_jwt.ts";
import { unixToIso } from "./time_unix_to_iso.ts";  ← from time.unix-to-iso ^1.0.0 · built alongside by fune

/** The latest second time.unix-to-iso can write. */
const MAX_EXP = 253402300799;

/** A failed check: every field but the reason is null. */
export function accessDenied(error: AccessError, message: string): AccessCheck {
  return { ok: false, subject: null, tokenVersion: null, jti: null, expiresAt: null, claims: null, error, message };
}

function isWhole(value: unknown): value is number {
  return typeof value === "number" && Number.isInteger(value);
}

/**
 * Who is making this request? The Bearer token from the Authorization
 * header, verified (signature, algorithm, exp, nbf, iat), and required to
 * carry the claims issueAccessToken writes. A missing or bad token is an
 * answer (401), never an exception.
 */
export function readAccessToken(authorization: string | null, secret: readonly number[], now: number, leewaySeconds: number): AccessCheck {
  checkJwtSecret(secret);
  if (!isWhole(now)) throw new TypeError("now must be a whole number of Unix seconds");
  if (!isWhole(leewaySeconds) || leewaySeconds < 0) throw new RangeError("leewaySeconds must be a whole number, 0 or more");

  const token = parseBearerToken(authorization);
  if (token === null) return accessDenied("missing_token", "the request has no Bearer token");
  const verified = verifyJwt(token, secret, now, leewaySeconds);
  if (!verified.valid || verified.claims === null) {
    return accessDenied(verified.error ?? "malformed_token", verified.message ?? "the token is not a well-formed JWT");
  }
  const claims = verified.claims;
  const { sub, jti, ver, exp } = claims as Record<string, unknown>;
  if (
    typeof sub !== "string" || sub.length === 0 ||
    typeof jti !== "string" || jti.length === 0 ||
    !isWhole(ver) || ver < 0 ||
    !isWhole(exp) || exp < 0 || exp > MAX_EXP
  ) {
    return accessDenied("invalid_claims", "the token lacks the sub, jti, ver or exp this API issues");
  }
  return { ok: true, subject: sub, tokenVersion: ver, jti, expiresAt: unixToIso(exp), claims, error: null, message: null };
}

confirmAccessToken throws on bad input 10 tests

export function confirmAccessToken(check: AccessCheck, currentTokenVersion: number | null, revoked: boolean): AccessCheck
checkAccessCheckwhat readAccessToken answered
currentTokenVersionint?the user's token version now, from the database; null if the user no longer exists
revokedboolwhether this token's jti is on the revoked list (logged out)
returnsAccessCheckthe same check when the token still stands; otherwise not ok

For example

  • confirmAccessToken(ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —, 0, false) → ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message — the user exists, the token version matches and the jti is not revoked
  • confirmAccessToken(ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —, 0, true) → ok false, subject —, token version —, jti —, expires at —, claims —, error token_revoked, message the token has been revoked by logging out logged out: the jti is on the revoked list
  • confirmAccessToken(ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —, 1, false) → ok false, subject —, token version —, jti —, expires at —, claims —, error token_revoked, message the token was issued before the user's tokens were revoked (password changed) the password changed: the user's token version moved on
import { confirmAccessToken } from "#fune/auth.access-token@^1";
impl/typescript/confirm_access_token.ts · 23 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { type AccessCheck } from "./auth_access_token_types.ts";
import { accessDenied } from "./auth_access_token_read_access_token.ts";  ← readAccessToken, another function of this group · built into the same file, even by a slim install

/**
 * The last word on a token readAccessToken accepted, once the caller has
 * looked up the user and the revoked list: a logged-out token, or one
 * issued before the user's token version was bumped (a password change),
 * no longer stands.
 */
export function confirmAccessToken(check: AccessCheck, currentTokenVersion: number | null, revoked: boolean): AccessCheck {
  if (typeof check !== "object" || check === null) throw new TypeError("check must be an AccessCheck");
  if (currentTokenVersion !== null && (typeof currentTokenVersion !== "number" || !Number.isInteger(currentTokenVersion))) {
    throw new TypeError("currentTokenVersion must be a whole number or null");
  }
  if (typeof revoked !== "boolean") throw new TypeError("revoked must be true or false");
  if (!check.ok) return check;
  if (currentTokenVersion === null) return accessDenied("user_not_found", "the token's user no longer exists");
  if (revoked) return accessDenied("token_revoked", "the token has been revoked by logging out");
  if (check.tokenVersion !== currentTokenVersion) {
    return accessDenied("token_revoked", "the token was issued before the user's tokens were revoked (password changed)");
  }
  return check;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add auth.access-token

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add auth.access-token --only issueAccessToken
Download for TypeScript auth.access-token-1.0.0-typescript.fune · 36,105 bytes sha256 1fba9b32e13f9b6369e71a4a026477391fe3dd4956fcdf4df4dd1bed9a69f4a2

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./auth.access-token-1.0.0-typescript.fune, or fetch it from a terminal with fune pull auth.access-token@1.0.0:typescript.

The whole function, every language, is one file too: auth.access-token-1.0.0.fune, 52,505 bytes, sha256 7888631aeb2e12d0f775ea5737dcd9b7cbed018c016908edfac9c05877c7b035. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before auth.access-token.issueAccessToken
// fune: before auth.access-token.readAccessToken
// fune: before auth.access-token.confirmAccessToken

after — your function gets the result and the arguments, and returns the final result.

// fune: after auth.access-token.issueAccessToken
// fune: after auth.access-token.readAccessToken
// fune: after auth.access-token.confirmAccessToken

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace auth.bearer-token in auth.access-token
// fune: replace auth.jwt in auth.access-token
// fune: replace time.unix-to-iso in auth.access-token

step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show auth.access-token --steps.

// fune: step auth.access-token.<fn> after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

issueAccessToken 10 tests

CaseArgumentsExpected
a one-hour token at noon expires at 13:00Z 42, Ada Lovelace, ada@example.com, 0, 9f86d081884c7d65, 1,790,424,000, 3,600, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97,… → access token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsImV4cCI6MTc5MDQyNzYwMCwiaWF0IjoxNzkwNDI0MDAwLCJqdGkiOiI5Zjg2ZDA4MTg4NGM3ZDY1IiwibmFtZSI6IkFkY…
a 15-minute token for a user whose tokens were revoked three times 7, Zoë Ölander, zoe@example.org, 3, jti-2, 1,790,424,000, 900, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, … → access token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InpvZUBleGFtcGxlLm9yZyIsImV4cCI6MTc5MDQyNDkwMCwiaWF0IjoxNzkwNDI0MDAwLCJqdGkiOiJqdGktMiIsIm5hbWUiOiJab8OrIMOWbGFuZGVyI…
a one-second token issued at 23:59:59 expires at midnight 42, Ada Lovelace, ada@example.com, 0, late, 1,790,510,399, 1, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 5… → access token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsImV4cCI6MTc5MDUxMDQwMCwiaWF0IjoxNzkwNTEwMzk5LCJqdGkiOiJsYXRlIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsI…
an empty name and email are allowed; the epoch as now 1, , , 0, x, 0, 1, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, … → access token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6IiIsImV4cCI6MSwiaWF0IjowLCJqdGkiOiJ4IiwibmFtZSI6IiIsInN1YiI6IjEiLCJ2ZXIiOjB9.iXhMTZBU3fhaUpqa0NrFRU6dUod71CLujxTCl4Nz…
a year-long token 42, Ada, ada@example.com, 0, j, 1,790,424,000, 31,536,000, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, … → access token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsImV4cCI6MTgyMTk2MDAwMCwiaWF0IjoxNzkwNDI0MDAwLCJqdGkiOiJqIiwibmFtZSI6IkFkYSIsInN1YiI6IjQyIiwid…
an empty subject , Ada, a@b.co, 0, j, 1,790,424,000, 3,600, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, … → error: subject must be a non-empty string
an empty jti cannot be revoked alone 42, Ada, a@b.co, 0, , 1,790,424,000, 3,600, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98,… → error: jti must be a non-empty string
a zero lifetime 42, Ada, a@b.co, 0, j, 1,790,424,000, 0, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 10… → error: ttlSeconds must be a whole number of at least 1
a negative token version 42, Ada, a@b.co, -1, j, 1,790,424,000, 3,600, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 9… → error: tokenVersion must be a whole number, 0 or more
a short secret 42, Ada, a@b.co, 0, j, 1,790,424,000, 3,600, 115, 104, 111, 114, 116 → error: secret must be at least 32 bytes

readAccessToken 13 tests

CaseArgumentsExpected
a token issueAccessToken made, half an hour in Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsImV4cCI6MTc5MDQyNzYwMCwiaWF0IjoxNzkwNDI0MDAwLCJqdGkiOiI5Zjg2ZDA4MTg4NGM3ZDY1IiwibmFtZSI6IkFkYSBMb3Z… → ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —
the scheme is case-insensitive bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsImV4cCI6MTc5MDQyNzYwMCwiaWF0IjoxNzkwNDI0MDAwLCJqdGkiOiI5Zjg2ZDA4MTg4NGM3ZDY1IiwibmFtZSI6IkFkYSBMb3Z… → ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —
no Authorization header —, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, 111, 110, 103, 1… → ok false, subject —, token version —, jti —, expires at —, claims —, error missing_token, message the request has no Bearer token
Basic credentials are not a Bearer token Basic dXNlcjpwYXNz, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108,… → ok false, subject —, token version —, jti —, expires at —, claims —, error missing_token, message the request has no Bearer token
expired: at exp plus the leeway Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsImV4cCI6MTc5MDQyNzYwMCwiaWF0IjoxNzkwNDI0MDAwLCJqdGkiOiI5Zjg2ZDA4MTg4NGM3ZDY1IiwibmFtZSI6IkFkYSBMb3Z… → ok false, subject —, token version —, jti —, expires at —, claims —, error token_expired, message the token has expired
still accepted one second before exp plus the leeway Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsImV4cCI6MTc5MDQyNzYwMCwiaWF0IjoxNzkwNDI0MDAwLCJqdGkiOiI5Zjg2ZDA4MTg4NGM3ZDY1IiwibmFtZSI6IkFkYSBMb3Z… → ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —
signed with another key Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkYUBleGFtcGxlLmNvbSIsImV4cCI6MTc5MDQyNzYwMCwiaWF0IjoxNzkwNDI0MDAwLCJqdGkiOiI5Zjg2ZDA4MTg4NGM3ZDY1IiwibmFtZSI6IkFkYSBMb3Z… → ok false, subject —, token version —, jti —, expires at —, claims —, error invalid_signature, message the token's signature does not match
not a JWT Bearer abc.def, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, 111… → ok false, subject —, token version —, jti —, expires at —, claims —, error malformed_token, message the token is not a well-formed JWT
a validly signed token without jti or ver is not one this API issued Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0MjQwNjAsInN1YiI6IjQyIn0.K2Az1-0K29sMjBQJlfg0S0S3Z49d3gE6l8u2QvkNUjM, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101… → ok false, subject —, token version —, jti —, expires at —, claims —, error invalid_claims, message the token lacks the sub, jti, ver or exp this API issues
a validly signed token with no exp would never expire, so it is refused Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJqIiwic3ViIjoiNDIiLCJ2ZXIiOjB9.hPnOOMcexeI2CIs8yxRegpJJl55Wg-JW0z09NyP6qrY, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 10… → ok false, subject —, token version —, jti —, expires at —, claims —, error invalid_claims, message the token lacks the sub, jti, ver or exp this API issues
Show the other 3 tests
CaseArgumentsExpected
a numeric sub is not the string this API writes Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0MjQwNjAsImp0aSI6ImoiLCJzdWIiOjQyLCJ2ZXIiOjB9.9d1Ta3t-bbJRfPdDlNhUJQ2QpPN4LRcLLsjXDuKsq2A, 97, 45, 115, 116, 114, 105, 1… → ok false, subject —, token version —, jti —, expires at —, claims —, error invalid_claims, message the token lacks the sub, jti, ver or exp this API issues
a short secret is a configuration error even with no header —, 115, 104, 111, 114, 116, 1,790,424,000, 30 → error: secret must be at least 32 bytes
negative leeway —, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45, 108, 101, 97, 115, 116, 45, 50, 53, 54, 45, 98, 105, 116, 115, 45, 108, 111, 110, 103, 1… → error: leewaySeconds must be a whole number, 0 or more

confirmAccessToken 10 tests

CaseArgumentsExpected
the user exists, the token version matches and the jti is not revoked ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —, 0, false → ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —
logged out: the jti is on the revoked list ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —, 0, true → ok false, subject —, token version —, jti —, expires at —, claims —, error token_revoked, message the token has been revoked by logging out
the password changed: the user's token version moved on ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —, 1, false → ok false, subject —, token version —, jti —, expires at —, claims —, error token_revoked, message the token was issued before the user's tokens were revoked (password changed)
the user was deleted ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —, —, false → ok false, subject —, token version —, jti —, expires at —, claims —, error user_not_found, message the token's user no longer exists
a check that already failed is passed through unchanged ok false, subject —, token version —, jti —, expires at —, claims —, error token_expired, message the token has expired, 0, false → ok false, subject —, token version —, jti —, expires at —, claims —, error token_expired, message the token has expired
a failed check stays failed even if not revoked and the version would match ok false, subject —, token version —, jti —, expires at —, claims —, error missing_token, message the request has no Bearer token, 0, false → ok false, subject —, token version —, jti —, expires at —, claims —, error missing_token, message the request has no Bearer token
version 3 matches version 3 ok true, subject 7, token version 3, jti jti-2, expires at 2026-09-26T12:15:00Z, claims …, error —, message —, 3, false → ok true, subject 7, token version 3, jti jti-2, expires at 2026-09-26T12:15:00Z, claims …, error —, message —
a stale version is refused even when the database's version is lower ok true, subject 7, token version 3, jti jti-2, expires at 2026-09-26T12:15:00Z, claims …, error —, message —, 2, false → ok false, subject —, token version —, jti —, expires at —, claims —, error token_revoked, message the token was issued before the user's tokens were revoked (password changed)
a fractional token version from the database ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —, 0.5, false → error: currentTokenVersion must be a whole number or null
revoked must be a boolean ok true, subject 42, token version 0, jti 9f86d081884c7d65, expires at 2026-09-26T13:00:00Z, claims …, error —, message —, 0, no → error: revoked must be true or false

More from the author

It is a group because the three share one token layout: `issueAccessToken` writes the claims `sub`, `name`, `email`, `ver`, `jti`, `iat` and `exp`, signed HS256 with `auth.jwt`, and `readAccessToken` insists on finding `sub`, `jti`, `ver` and `exp` in anything it accepts. A token that is validly signed but lacks them (or has no `exp`, so would never expire) is `invalid_claims`.

**Revocation** takes two forms, and `confirmAccessToken` applies both once the caller has done the lookups a pure function cannot:

- **Log out** revokes one token: store its `jti` (until its `expiresAt`, after which it is dead anyway) and pass `revoked = true` when it comes back. - **Change password** revokes every token the user holds: increment the user's token version in the database. Every token carries the version it was issued at (`ver`), and one that no longer matches is refused. - A user who no longer exists (`currentTokenVersion` null) is `user_not_found`.

A check that has already failed passes through `confirmAccessToken` unchanged, so the three calls can be chained without branching.

Nothing here reads the clock or makes randomness: `now` is Unix seconds from the caller and `jti` is random text from the caller (for instance `secrets.token_urlsafe(16)`). `expiresAt` is written by `time.unix-to-iso`. Errors from `readAccessToken` are `auth.jwt`'s codes plus `missing_token` (no `Authorization: Bearer` header, read with `auth.bearer-token`); an API should answer all of them with the same 401 and keep the code for its logs.

A secret shorter than 32 bytes, a negative leeway, an empty subject or jti, a negative token version or a lifetime under one second are the caller's mistakes and throw.

Sources: RFC 7519 sections 4.1.2 (sub), 4.1.4 (exp), 4.1.6 (iat), 4.1.7 (jti) (https://www.rfc-editor.org/rfc/rfc7519); RFC 6750 section 3, the WWW-Authenticate response for 401 (https://www.rfc-editor.org/rfc/rfc6750).

Files

PathBytes
README.md2,661
impl/python/confirm_access_token.py1,320
impl/python/issue_access_token.py1,781
impl/python/read_access_token.py2,640
impl/rust/confirm_access_token.rs2,180
impl/rust/issue_access_token.rs3,142
impl/rust/read_access_token.rs4,462
impl/typescript/confirm_access_token.ts1,351
impl/typescript/issue_access_token.ts1,695
impl/typescript/read_access_token.ts2,328
vectors.json18,587