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.
- issue_access_token (subject: string, name: string, email: string, tokenVersion: int, jti: string, now: int, ttlSeconds: int, secret: int[]) -> AccessToken
- read_access_token (authorization: string?, secret: int[], now: int, leewaySeconds: int) -> AccessCheck
- confirm_access_token (check: AccessCheck, currentTokenVersion: int?, revoked: bool) -> AccessCheck
The types it declares, generated into your project
@dataclass(frozen=True)
class AccessToken:
"""A freshly issued access token, shaped for a login response."""
access_token: str
#: always Bearer
token_type: str
#: ISO 8601 UTC, such as 2026-09-26T13:00:00Z
expires_at: str
#: seconds from now
expires_in: int
@dataclass(frozen=True)
class AccessCheck:
"""Whether a request carries a token that may be trusted, and whose it is."""
ok: bool
#: the user's id, when ok
subject: Optional[str]
#: the version the token was issued at, when ok
token_version: Optional[int]
#: the token's id, when ok; revoke this on logout
jti: Optional[str]
#: ISO 8601 UTC, when ok
expires_at: Optional[str]
#: every claim, when ok
claims: Optional[Mapping[str, Any]]
#: why not, when not ok
error: Optional[AccessError]
#: the reason in words, for logs
message: Optional[str]
AccessError = Literal["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.
issue_access_token throws on bad input 10 tests
def issue_access_token(subject: str, name: str, email: str, token_version: int, jti: str, now: int, ttl_seconds: int, secret: Sequence[int]) -> AccessToken
| subject | string | the user's id, as text (JWT sub is a string) |
| name | string | shown by the client without another request |
| string | shown by the client without another request | |
| token_version | int | the user's current token version; bump it to revoke every token they hold |
| jti | string | a unique id for this token, random from the caller, so it can be revoked alone |
| now | int | the current time in Unix seconds, read by the caller |
| ttl_seconds | int | lifetime, 1 or more; 3600 for an hour |
| secret | int[] | the signing key, at least 32 bytes |
| returns | AccessToken | the token and what a login response says about it |
For example
issue_access_token(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:00Zissue_access_token(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 timesissue_access_token(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
from fune.auth.access_token import issue_access_token # auth.access-token@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Any, Sequence
from .auth_access_token_types import AccessToken
from .auth_jwt_sign_jwt import sign_jwt
from .time_unix_to_iso import unix_to_iso ← from time.unix-to-iso ^1.0.0 · built alongside by fune
def _is_whole(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def issue_access_token(
subject: str,
name: str,
email: str,
token_version: int,
jti: str,
now: int,
ttl_seconds: int,
secret: Sequence[int],
) -> AccessToken:
"""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."""
if not isinstance(subject, str) or len(subject) == 0:
raise TypeError("subject must be a non-empty string")
if not isinstance(name, str):
raise TypeError("name must be a string")
if not isinstance(email, str):
raise TypeError("email must be a string")
if not _is_whole(token_version) or token_version < 0:
raise ValueError("tokenVersion must be a whole number, 0 or more")
if not isinstance(jti, str) or len(jti) == 0:
raise TypeError("jti must be a non-empty string")
if not _is_whole(now):
raise TypeError("now must be a whole number of Unix seconds")
if not _is_whole(ttl_seconds) or ttl_seconds < 1:
raise ValueError("ttlSeconds must be a whole number of at least 1")
exp = now + ttl_seconds
expires_at = unix_to_iso(exp)
token = sign_jwt(
{"sub": subject, "name": name, "email": email, "ver": token_version, "jti": jti, "iat": now, "exp": exp},
secret,
)
return AccessToken(access_token=token, token_type="Bearer", expires_at=expires_at, expires_in=ttl_seconds)read_access_token throws on bad input 13 tests
def read_access_token(authorization: Optional[str], secret: Sequence[int], now: int, leeway_seconds: int) -> AccessCheck
| authorization | string? | the Authorization header's value, or null when absent |
| secret | int[] | |
| now | int | the current time in Unix seconds |
| leeway_seconds | int | clock skew allowed, 0 or more |
| returns | AccessCheck | ok with the user's subject, token version and jti; or not ok with the reason |
For example
read_access_token(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 inread_access_token(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-insensitiveread_access_token(—, 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
from fune.auth.access_token import read_access_token # auth.access-token@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Any, Optional, Sequence
from .auth_access_token_types import AccessCheck
from .auth_bearer_token import parse_bearer_token ← from auth.bearer-token ^1.0.0 · built alongside by fune
from .auth_jwt_decode_jwt import check_jwt_secret
from .auth_jwt_verify_jwt import verify_jwt
from .time_unix_to_iso import unix_to_iso ← from time.unix-to-iso ^1.0.0 · built alongside by fune
#: The latest second time.unix-to-iso can write.
MAX_EXP = 253402300799
def access_denied(error: str, message: str) -> AccessCheck:
"""A failed check: every field but the reason is None."""
return AccessCheck(
ok=False, subject=None, token_version=None, jti=None, expires_at=None, claims=None, error=error, message=message
)
def _whole(value: Any) -> Optional[int]:
# A JSON 1.0 is a float in Python and 1 in JavaScript; both are whole.
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float) and value.is_integer():
return int(value)
return None
def read_access_token(authorization: Optional[str], secret: Sequence[int], now: int, leeway_seconds: int) -> AccessCheck:
"""Who is making this request? The Bearer token from the Authorization
header, verified, and required to carry the claims issue_access_token
writes. A missing or bad token is an answer (401), never an exception."""
check_jwt_secret(secret)
if isinstance(now, bool) or not isinstance(now, int):
raise TypeError("now must be a whole number of Unix seconds")
if isinstance(leeway_seconds, bool) or not isinstance(leeway_seconds, int) or leeway_seconds < 0:
raise ValueError("leewaySeconds must be a whole number, 0 or more")
token = parse_bearer_token(authorization)
if token is None:
return access_denied("missing_token", "the request has no Bearer token")
verified = verify_jwt(token, secret, now, leeway_seconds)
if not verified.valid or verified.claims is None:
return access_denied(verified.error or "malformed_token", verified.message or "the token is not a well-formed JWT")
claims = verified.claims
sub, jti = claims.get("sub"), claims.get("jti")
ver, exp = _whole(claims.get("ver")), _whole(claims.get("exp"))
if (
not isinstance(sub, str) or len(sub) == 0
or not isinstance(jti, str) or len(jti) == 0
or ver is None or ver < 0
or exp is None or exp < 0 or exp > MAX_EXP
):
return access_denied("invalid_claims", "the token lacks the sub, jti, ver or exp this API issues")
return AccessCheck(
ok=True, subject=sub, token_version=ver, jti=jti, expires_at=unix_to_iso(exp), claims=claims, error=None, message=None
)confirm_access_token throws on bad input 10 tests
def confirm_access_token(check: AccessCheck, current_token_version: Optional[int], revoked: bool) -> AccessCheck
| check | AccessCheck | what readAccessToken answered |
| current_token_version | int? | the user's token version now, from the database; null if the user no longer exists |
| revoked | bool | whether this token's jti is on the revoked list (logged out) |
| returns | AccessCheck | the same check when the token still stands; otherwise not ok |
For example
confirm_access_token(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 revokedconfirm_access_token(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 listconfirm_access_token(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
from fune.auth.access_token import confirm_access_token # auth.access-token@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Optional
from .auth_access_token_read_access_token import access_denied ← readAccessToken, another function of this group · built into the same file, even by a slim install
from .auth_access_token_types import AccessCheck
def confirm_access_token(check: AccessCheck, current_token_version: Optional[int], revoked: bool) -> AccessCheck:
"""The last word on a token read_access_token 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."""
if current_token_version is not None and (
isinstance(current_token_version, bool) or not isinstance(current_token_version, int)
):
raise TypeError("currentTokenVersion must be a whole number or null")
if not isinstance(revoked, bool):
raise TypeError("revoked must be true or false")
if not check.ok:
return check
if current_token_version is None:
return access_denied("user_not_found", "the token's user no longer exists")
if revoked:
return access_denied("token_revoked", "the token has been revoked by logging out")
if check.token_version != current_token_version:
return access_denied(
"token_revoked", "the token was issued before the user's tokens were revoked (password changed)"
)
return checkInstall
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Python 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
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./auth.access-token-1.0.0-python.fune, or fetch it from a terminal with fune pull auth.access-token@1.0.0:python.
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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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).