auth.jwt
Sign, verify and decode HS256 JSON Web Tokens (RFC 7519): signature, algorithm and exp/nbf/iat checked with leeway.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 54 tests, run in TypeScript, Python and Rust.signJwt 14 · verifyJwt 22 · decodeJwt 18
What it does
HS256 JSON Web Tokens: `signJwt` makes one, `verifyJwt` decides whether to trust one, and `decodeJwt` reads one without the key. They are a group because they share one token format and one JSON reader; install only what each side needs (`only=decodeJwt` in a browser, which never holds the secret).
token = signJwt({"sub": "42", "exp": now + 3600}, secret)
result = verifyJwt(token, secret, now, 30) # {valid, claims, error, message}
claims = decodeJwt(token) # no key; null if malformed
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.
- signJwt (claims: record, secret: int[]) -> string
- verifyJwt (token: string, secret: int[], now: int, leewaySeconds: int) -> JwtVerification
- decodeJwt (token: string) -> record?
The types it declares, generated into your project
/** Whether a token may be trusted and, when it may, what it says. */
export interface JwtVerification {
readonly valid: boolean;
/** the payload, when valid */
readonly claims: Readonly<Record<string, unknown>> | null;
/** why not, when not valid */
readonly error: JwtError | null;
/** the reason in words, for logs rather than end users */
readonly message: string | null;
}
export type JwtError = "malformed_token" | "unsupported_algorithm" | "invalid_signature" | "invalid_claims" | "token_expired" | "token_not_yet_valid" | "token_issued_in_future";
Once installed, your code imports each one from the group's module.
signJwt throws on bad input 14 tests
export function signJwt(claims: Readonly<Record<string, unknown>>, secret: readonly number[]): string
| claims | record | a JSON object; numbers must be whole and within 2^53, keys are written in sorted order |
| secret | int[] | at least 32 bytes (RFC 7518 section 3.2); encoding.utf8 for a text secret |
| returns | string | header.payload.signature, each part base64url without padding |
For example
signJwt(sub 42, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 4…)→ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… a typical access token's claimssignJwt(z 1, a 2, m 3, 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,…)→ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoyLCJtIjozLCJ6IjoxfQ.2d-Ui9ktahBOXMUFlTIJz8Qj9znIFP5P2B8_L_zT3d0 keys given in any order are written sorted, so equal claims make equal tokenssignJwt(, 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)→ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.8VKCTiBegJPuPIZlp0wbV0Sbdn5BS6TE5DCx6oYNc5o an empty claims object
import { signJwt } from "#fune/auth.jwt@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { checkJwtSecret } from "./auth_jwt_decode_jwt.ts"; ← decodeJwt, another function of this group · built into the same file, even by a slim install
import { base64UrlEncode } from "./encoding_base64_base64_url_encode.ts";
import { utf8Encode } from "./encoding_utf8_utf8_encode.ts";
import { hmacSha256 } from "./crypto_hmac_sha256.ts"; ← from crypto.hmac-sha256 ^1.0.0 · built alongside by fune
/** base64url of {"alg":"HS256","typ":"JWT"}: the header is fixed, so it is written once. */
const HEADER = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
const MAX_DEPTH = 32;
const MAX_SAFE = 9007199254740991;
/** Code point order, which is how Python and Rust sort strings; JavaScript's default sort compares UTF-16 units. */
function compareCodePoints(a: string, b: string): number {
const x = Array.from(a);
const y = Array.from(b);
for (let i = 0; i < x.length && i < y.length; i++) {
const d = (x[i].codePointAt(0) as number) - (y[i].codePointAt(0) as number);
if (d !== 0) return d;
}
return x.length - y.length;
}
function quote(text: string): string {
let out = '"';
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const c = text.charCodeAt(i);
if (ch === '"') out += '\\"';
else if (ch === "\\") out += "\\\\";
else if (ch === "\n") out += "\\n";
else if (ch === "\r") out += "\\r";
else if (ch === "\t") out += "\\t";
else if (ch === "\b") out += "\\b";
else if (ch === "\f") out += "\\f";
else if (c < 0x20) out += "\\u" + c.toString(16).padStart(4, "0");
else out += ch;
}
return out + '"';
}
/**
* One canonical JSON text for a value: no spaces, object keys sorted by code
* point, whole numbers only, non-ASCII written as UTF-8 rather than escaped.
* Every language writes the same bytes, so the same claims sign to the same
* token everywhere.
*/
function canonicalJson(value: unknown, depth: number): string {
if (value === null) return "null";
if (value === true) return "true";
if (value === false) return "false";
if (typeof value === "number") {
if (!Number.isInteger(value) || Math.abs(value) > MAX_SAFE) {
throw new RangeError("claims may only hold whole numbers from -(2^53 - 1) to 2^53 - 1");
}
return String(value === 0 ? 0 : value);
}
if (typeof value === "string") return quote(value);
if (typeof value !== "object") throw new TypeError("claims must hold only JSON values");
if (depth > MAX_DEPTH) throw new RangeError("claims are nested more than 32 deep");
if (Array.isArray(value)) return "[" + value.map((item) => canonicalJson(item, depth + 1)).join(",") + "]";
const entries = Object.keys(value as object)
.sort(compareCodePoints)
.map((key) => quote(key) + ":" + canonicalJson((value as Record<string, unknown>)[key], depth + 1));
return "{" + entries.join(",") + "}";
}
/**
* An HS256 JWT for the claims: header {"alg":"HS256","typ":"JWT"}, the
* claims as canonical JSON, and the HMAC-SHA256 of the two under the secret.
* Put exp (and usually iat, sub and jti) in the claims yourself, or use
* auth.access-token, which does.
*/
export function signJwt(claims: Readonly<Record<string, unknown>>, secret: readonly number[]): string {
checkJwtSecret(secret);
if (typeof claims !== "object" || claims === null || Array.isArray(claims)) {
throw new TypeError("claims must be a JSON object");
}
const signingInput = HEADER + "." + base64UrlEncode(utf8Encode(canonicalJson(claims, 1)));
return signingInput + "." + base64UrlEncode(hmacSha256(secret, utf8Encode(signingInput)));
}verifyJwt throws on bad input 22 tests
export function verifyJwt(token: string, secret: readonly number[], now: number, leewaySeconds: number): JwtVerification
| token | string | the compact JWT, as parsed from the Authorization header |
| secret | int[] | the key it should be signed with, at least 32 bytes |
| now | int | the current time in Unix seconds, read by the caller |
| leewaySeconds | int | clock skew allowed on exp, nbf and iat, 0 or more; 30 to 60 is usual |
| returns | JwtVerification | valid with the claims, or not valid with an error code; a bad token never throws |
For example
verifyJwt(eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk…)→ valid true, claims …, error —, message — RFC 7515 appendix A.1: the example token, one second before expverifyJwt(eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk…)→ valid false, claims —, error token_expired, message the token has expired RFC 7515 appendix A.1: at exp exactly the token has expired (now must be before exp)verifyJwt(eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk…)→ valid true, claims …, error —, message — RFC 7515 appendix A.1: 60 seconds of leeway still accepts it at exp + 59
import { verifyJwt } from "#fune/auth.jwt@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { checkJwtSecret, splitJwt } from "./auth_jwt_decode_jwt.ts"; ← decodeJwt, another function of this group · built into the same file, even by a slim install
import { type JwtError, type JwtVerification } from "./auth_jwt_types.ts";
import { hmacSha256 } from "./crypto_hmac_sha256.ts"; ← from crypto.hmac-sha256 ^1.0.0 · built alongside by fune
import { constantTimeEqual } from "./crypto_constant_time_equal.ts"; ← from crypto.constant-time-equal ^1.0.0 · built alongside by fune
import { utf8Encode } from "./encoding_utf8_utf8_encode.ts";
function fail(error: JwtError, message: string): JwtVerification {
return { valid: false, claims: null, error, message };
}
/**
* Check an HS256 token: its shape, that it says HS256 (so "none" and
* algorithm-confusion tokens are refused before any key is used), its
* signature in constant time, then exp, nbf and iat against `now` with
* leeway. A bad token is an answer, never an exception; only a bad secret,
* `now` or leeway (the caller's own mistake) throws.
*/
export function verifyJwt(token: string, secret: readonly number[], now: number, leewaySeconds: number): JwtVerification {
checkJwtSecret(secret);
if (typeof now !== "number" || !Number.isInteger(now)) {
throw new TypeError("now must be a whole number of Unix seconds");
}
if (typeof leewaySeconds !== "number" || !Number.isInteger(leewaySeconds) || leewaySeconds < 0) {
throw new RangeError("leewaySeconds must be a whole number, 0 or more");
}
const parts = splitJwt(token);
if (parts === null) return fail("malformed_token", "the token is not a well-formed JWT");
if (parts.header.alg !== "HS256") return fail("unsupported_algorithm", "only HS256 tokens are accepted");
if (Object.prototype.hasOwnProperty.call(parts.header, "crit")) {
return fail("unsupported_algorithm", "the token requires header extensions (crit) this verifier does not support");
}
const expected = hmacSha256(secret, utf8Encode(parts.signingInput));
if (!constantTimeEqual(expected, parts.signature)) {
return fail("invalid_signature", "the token's signature does not match");
}
const claims = parts.claims;
for (const name of ["exp", "nbf", "iat"]) {
if (Object.prototype.hasOwnProperty.call(claims, name) && typeof claims[name] !== "number") {
return fail("invalid_claims", `the token's ${name} claim is not a number`);
}
}
const has = (name: string): boolean => Object.prototype.hasOwnProperty.call(claims, name);
// RFC 7519 4.1.4: the current time MUST be before exp.
if (has("exp") && now >= (claims.exp as number) + leewaySeconds) {
return fail("token_expired", "the token has expired");
}
// RFC 7519 4.1.5: the current time MUST be at or after nbf.
if (has("nbf") && now + leewaySeconds < (claims.nbf as number)) {
return fail("token_not_yet_valid", "the token is not valid yet");
}
if (has("iat") && (claims.iat as number) > now + leewaySeconds) {
return fail("token_issued_in_future", "the token was issued in the future");
}
return { valid: true, claims, error: null, message: null };
}decodeJwt 18 tests
export function decodeJwt(token: string): Readonly<Record<string, unknown>> | null
| token | string | a compact JWT |
| returns | record? | its claims WITHOUT checking the signature, for a browser to read exp or name; null if malformed |
For example
decodeJwt(eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk)→ iss joe, exp 1,300,819,380, http://example.com/is_root true RFC 7515 appendix A.1: the example token's claimsdecodeJwt(eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy…)→ sub 42, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1 a token signJwt madedecodeJwt(eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiIxIiwidmVyIjoxfQ.Ohi2VyS…)→ sub 1, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1 the signature is not checked: tampered claims still decode
import { decodeJwt } from "#fune/auth.jwt@^1";
import { base64UrlDecode } from "./encoding_base64_base64_url_decode.ts";
import { utf8Decode } from "./encoding_utf8_utf8_decode.ts";
/** Containers nested deeper than this are refused, in every language, rather than left to each parser's own limit. */
const MAX_DEPTH = 32;
const MAX_SAFE = 9007199254740991;
/**
* Is this one canonical, unpadded base64url segment? Checked before decoding
* so that a hostile token is an answer (null), never a thrown error, and so
* a segment has exactly one spelling (the unused low bits must be zero).
*/
function isSegment(text: string, allowEmpty: boolean): boolean {
if (text.length === 0) return allowEmpty;
if (text.length % 4 === 1) return false;
let last = 0;
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
let v: number;
if (c >= 65 && c <= 90) v = c - 65;
else if (c >= 97 && c <= 122) v = c - 71;
else if (c >= 48 && c <= 57) v = c + 4;
else if (c === 45) v = 62;
else if (c === 95) v = 63;
else return false;
last = v;
}
if (text.length % 4 === 2) return (last & 15) === 0;
if (text.length % 4 === 3) return (last & 3) === 0;
return true;
}
function hasLoneSurrogate(text: string): boolean {
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
if (c >= 0xd800 && c <= 0xdbff) {
const next = i + 1 < text.length ? text.charCodeAt(i + 1) : 0;
if (next >= 0xdc00 && next <= 0xdfff) {
i++;
continue;
}
return true;
}
if (c >= 0xdc00 && c <= 0xdfff) return true;
}
return false;
}
/**
* JSON.parse accepts some things Python's and Rust's parsers answer
* differently: integers beyond 2^53 (silently rounded here), overflowing
* exponents (Infinity), escaped lone surrogates and unbounded nesting. Each
* is refused in all three, so a token decodes the same everywhere.
*/
function isPortable(value: unknown, depth: number): boolean {
if (typeof value === "number") {
return Number.isFinite(value) && (!Number.isInteger(value) || Math.abs(value) <= MAX_SAFE);
}
if (typeof value === "string") return !hasLoneSurrogate(value);
if (value === null || typeof value === "boolean") return true;
if (depth > MAX_DEPTH) return false;
if (Array.isArray(value)) return value.every((item) => isPortable(item, depth + 1));
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
if (hasLoneSurrogate(key) || !isPortable(item, depth + 1)) return false;
}
return true;
}
function parseObject(segment: string): Record<string, unknown> | null {
let parsed: unknown;
try {
parsed = JSON.parse(utf8Decode(base64UrlDecode(segment)));
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return isPortable(parsed, 1) ? (parsed as Record<string, unknown>) : null;
}
/**
* A compact JWT split into its decoded parts, or null when it is not three
* canonical base64url segments whose first two are JSON objects. Nothing is
* verified here; verifyJwt builds on it.
*/
export function splitJwt(token: string): { header: Record<string, unknown>; claims: Record<string, unknown>; signingInput: string; signature: readonly number[] } | null {
if (typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
if (!isSegment(parts[0], false) || !isSegment(parts[1], false) || !isSegment(parts[2], true)) return null;
const header = parseObject(parts[0]);
const claims = parseObject(parts[1]);
if (header === null || claims === null) return null;
return { header, claims, signingInput: parts[0] + "." + parts[1], signature: base64UrlDecode(parts[2]) };
}
/**
* The secret check shared by signJwt and verifyJwt. RFC 7518 section 3.2:
* an HS256 key MUST be at least as long as the hash, 256 bits.
*/
export function checkJwtSecret(secret: readonly number[]): void {
if (!Array.isArray(secret) && !(secret instanceof Uint8Array)) {
throw new TypeError("secret must be a list of integers from 0 to 255");
}
for (let i = 0; i < secret.length; i++) {
const b = secret[i];
if (typeof b !== "number" || !Number.isInteger(b) || b < 0 || b > 255) {
throw new RangeError("secret must be a list of integers from 0 to 255");
}
}
if (secret.length < 32) {
throw new RangeError(`secret must be at least 32 bytes (RFC 7518 section 3.2), received ${secret.length}`);
}
}
/**
* The claims of a JWT WITHOUT checking its signature, or null when it is
* malformed. For a browser that holds its own token and wants to read `exp`
* or `name`; never for deciding whether to trust a token.
*/
export function decodeJwt(token: string): Readonly<Record<string, unknown>> | null {
const parts = splitJwt(token);
return parts === null ? null : parts.claims;
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 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.jwt
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add auth.jwt --only signJwt
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./auth.jwt-1.0.0-typescript.fune, or fetch it from a terminal with fune pull auth.jwt@1.0.0:typescript.
The whole function, every language, is one file too: auth.jwt-1.0.0.fune, 76,496 bytes, sha256 d49b933c8a31983881fb84fd097f957edda9112aa4ef3ceab659720a82a5849e. 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.jwt.signJwt
// fune: before auth.jwt.verifyJwt
// fune: before auth.jwt.decodeJwt
after — your function gets the result and the arguments, and returns the final result.
// fune: after auth.jwt.signJwt
// fune: after auth.jwt.verifyJwt
// fune: after auth.jwt.decodeJwt
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 crypto.constant-time-equal in auth.jwt
// fune: replace crypto.hmac-sha256 in auth.jwt
// fune: replace encoding.base64 in auth.jwt
// fune: replace encoding.utf8 in auth.jwt
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.jwt --steps.
// fune: step auth.jwt.<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.
signJwt 14 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a typical access token's claims | sub 42, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 4… | → | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… |
| keys given in any order are written sorted, so equal claims make equal tokens | z 1, a 2, m 3, 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,… | → | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoyLCJtIjozLCJ6IjoxfQ.2d-Ui9ktahBOXMUFlTIJz8Qj9znIFP5P2B8_L_zT3d0 |
| an empty claims object | , 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 | → | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.8VKCTiBegJPuPIZlp0wbV0Sbdn5BS6TE5DCx6oYNc5o |
| nested objects and arrays, sorted at every level | roles admin, user, profile …, active true, deleted —, 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, 5… | → | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3RpdmUiOnRydWUsImRlbGV0ZWQiOm51bGwsInByb2ZpbGUiOnsibGFuZyI6ImVuIiwidGhlbWUiOiJkYXJrIn0sInJvbGVzIjpbImFkbWluIiwidXNlciJdfQ.4fLvEPiIqWWX2bO… |
| non-ASCII is written as UTF-8, not \u escapes | name Zoë Łukasz 日本, 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,… | → | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiWm_DqyDFgXVrYXN6IOaXpeacrCJ9.iTNgXXEa7vzonTGJwFoDU9FeF39moGS4sOxy4vtfQvY |
| quotes, backslashes and control characters are escaped the JSON way | note say "hi"\ , 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, … | → | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub3RlIjoic2F5IFwiaGlcIlxcXG5cdFx1MDAwMSJ9.0facH1f7Xt_3FZvr7KfVCvJhnGlzvayMbBrIk9UUN2o |
| keys sort by code point: U+FF01 before an emoji, which UTF-16 order gets backwards | 😀 1, ! 2, 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… | → | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyLvvIEiOjIsIvCfmIAiOjF9.Y3s5mOsOg8DcrEgACfh9lcX3WJQ1k1jJNxA_GqDoV0Q |
| the RFC 7515 key signs the RFC's claims to a different, canonical token | iss joe, exp 1,300,819,380, http://example.com/is_root true, 3, 35, 53, 75, 43, 15, 165, 188, 131, 126, 6, 101, 119, 123, 166, 143, 90, 179, 40, 230, 240, 84, 201, 40, 169, 15, 13… | → | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJpc3MiOiJqb2UifQ.tu77b1J0ZCHMDd3tWZm36iolxZtBRaArSrtayOBDO34 |
| negative and zero numbers | a -5, b 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, 105, 116, 115, 45, 108, 111, 110… | → | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjotNSwiYiI6MH0.F2dwrxljP90TApwtxG2T4ArY1T2xO0NOyN8UWpXCtKg |
| a secret shorter than 32 bytes is refused | sub 1, 116, 111, 111, 45, 115, 104, 111, 114, 116, 45, 115, 101, 99, 114, 101, 116 | → | error: secret must be at least 32 bytes (RFC 7518 section 3.2), received 16 |
Show the other 4 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| claims must be an object, not a list | 1, 2, 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 | → | error: claims must be a JSON object |
| a fractional number | exp 1,790,427,600.5, 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… | → | error: claims may only hold whole numbers |
| an integer beyond 2^53, which JavaScript cannot hold exactly | id 9,007,199,254,740,992, 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… | → | error: claims may only hold whole numbers |
| a secret byte out of range | a 1, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256 | → | error: secret must be a list of integers from 0 to 255 |
verifyJwt 22 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| RFC 7515 appendix A.1: the example token, one second before exp | eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk… | → | valid true, claims …, error —, message — |
| RFC 7515 appendix A.1: at exp exactly the token has expired (now must be before exp) | eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk… | → | valid false, claims —, error token_expired, message the token has expired |
| RFC 7515 appendix A.1: 60 seconds of leeway still accepts it at exp + 59 | eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk… | → | valid true, claims …, error —, message — |
| RFC 7515 appendix A.1: under the wrong key the signature does not match | eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk… | → | valid false, claims —, error invalid_signature, message the token's signature does not match |
| a token signJwt made, halfway through its hour | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… | → | valid true, claims …, error —, message — |
| claims changed after signing (sub 42 made 1) fail the signature | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiIxIiwidmVyIjoxfQ.Ohi2VyS… | → | valid false, claims —, error invalid_signature, message the token's signature does not match |
| alg none with no signature is refused before any key is used | eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0., 97, 4… | → | valid false, claims —, error unsupported_algorithm, message only HS256 tokens are accepted |
| an RS256 header is refused, whatever the signature | eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.fOeyYK… | → | valid false, claims —, error unsupported_algorithm, message only HS256 tokens are accepted |
| a crit header naming extensions this verifier does not know | eyJhbGciOiJIUzI1NiIsImNyaXQiOlsiZXhwIl19.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.wk… | → | valid false, claims —, error unsupported_algorithm, message the token requires header extensions (crit) this verifier does not support |
| nbf in the future beyond the leeway | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3OTA0MjYwMDAsInN1YiI6IjQyIn0.iv6Duh1mux_SO9e9idddbgeIJZpp1brgaBmgJWgKFHk, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 1… | → | valid false, claims —, error token_not_yet_valid, message the token is not valid yet |
Show the other 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| nbf in the future but within the leeway | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3OTA0MjU4MjAsInN1YiI6IjQyIn0.0SnbadIZHfgVQvAskhVMs5VYJxgCIVMvRvGjZWn6R6U, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 1… | → | valid true, claims …, error —, message — |
| iat in the future beyond the leeway | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3OTA0MjYwMDAsInN1YiI6IjQyIn0.qGztfRKO451NyNWZ2MjSGgKVkbB5vPDURwY_J4rbxaw, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 1… | → | valid false, claims —, error token_issued_in_future, message the token was issued in the future |
| exp as a string is not a NumericDate | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOiIxNzkwNDI3NjAwIiwic3ViIjoiNDIifQ.OYcP5eZGvaHjXOXC9u_luKmy2P0Ga8Qwf3DpVbu4YQ0, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99… | → | valid false, claims —, error invalid_claims, message the token's exp claim is not a number |
| only two parts | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0, 97, 4… | → | valid false, claims —, error malformed_token, message the token is not a well-formed JWT |
| the empty string | , 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,… | → | valid false, claims —, error malformed_token, message the token is not a well-formed JWT |
| a payload that is JSON but not an object | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.WzEsMiwzXQ.awvlufPKfBwu7aTZYEZMDJ6ryhHw48sgLbPzQcBTFwU, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, 45… | → | valid false, claims —, error malformed_token, message the token is not a well-formed JWT |
| base64 padding in a segment is not the compact form | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… | → | valid false, claims —, error malformed_token, message the token is not a well-formed JWT |
| a payload that is not UTF-8 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoi_yJ9.Ohi2VySpSMFwlM-7lTI51a2O-0PfhwjlVb5jmoS5yeU, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 116, … | → | valid false, claims —, error malformed_token, message the token is not a well-formed JWT |
| NaN in the payload, which Python's parser would otherwise accept | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOk5hTn0.Z3VEeJ4ChV9SwwVk9QRNNuHKucV7V2MeeGcLjD7QlPw, 97, 45, 115, 116, 114, 105, 110, 103, 45, 115, 101, 99, 114, 101, 116, 45, 97, 11… | → | valid false, claims —, error malformed_token, message the token is not a well-formed JWT |
| a secret shorter than 32 bytes is the caller's error, not a token answer | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… | → | error: secret must be at least 32 bytes (RFC 7518 section 3.2), received 5 |
| negative leeway | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… | → | error: leewaySeconds must be a whole number, 0 or more |
| now given in fractional seconds | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… | → | error: now must be a whole number of Unix seconds |
decodeJwt 18 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| RFC 7515 appendix A.1: the example token's claims | eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk | → | iss joe, exp 1,300,819,380, http://example.com/is_root true |
| a token signJwt made | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… | → | sub 42, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1 |
| the signature is not checked: tampered claims still decode | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiIxIiwidmVyIjoxfQ.Ohi2VyS… | → | sub 1, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1 |
| an alg none token decodes (and verifyJwt would refuse it) | eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0. | → | sub 42, name Ada Lovelace, iat 1,790,424,000, exp 1,790,427,600, jti c3VwZXItdW5pcXVl, ver 1 |
| an expired token still decodes; reading exp is the point | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjF9.kM3H33Lrr02vuN2nyW3tLvAeOhxBDnjRcdmnSsE3stM | → | exp 1 |
| not a JWT at all | hello | → | — |
| the empty string | → | — | |
| four parts | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2Vy… | → | — |
| a header that is not JSON | bm90IGpzb24.eyJleHAiOjE3OTA0Mjc2MDAsImlhdCI6MTc5MDQyNDAwMCwianRpIjoiYzNWd1pYSXRkVzVwY1hWbCIsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJzdWIiOiI0MiIsInZlciI6MX0.Ohi2VySpSMFwlM-7lTI51a2O-0Pfhwj… | → | — |
| a payload that is a JSON string, not an object | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ImhlbGxvIg.xlSnpdTxzJP-c6aHtil6OidO18-O1_dnQGbk9JlHDUw | → | — |
Show the other 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| an integer beyond 2^53 is refused, since JavaScript would round it | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTAwNzE5OTI1NDc0MDk5M30.wnSgywnzmbeCIbzKicr_mYOSWTR6c1tibW2tPZU1SLc | → | — |
| an escaped lone surrogate is refused | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiXHVkODAwIn0.NbmV3UAJ6BUq9zpwOTy6lPV1YFGXZhgkSGggtfVQHxE | → | — |
| an escaped surrogate pair is one character | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiXHVkODNkXHVkZTAwIn0.lbtCXl2nVVxdtXDvCaPTzf2kGfd_x0_dIJLFBGXEh60 | → | name 😀 |
| the canonical form of the same small token decodes | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoxfQ.pHz1uz9qdaLadovi6pLeKPvCegaT6WHF2YnrA-eT7qw | → | a 1 |
| non-canonical base64url: the payload's last character carries non-zero spare bits (a lenient decoder reads the same claims) | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoxfR.pHz1uz9qdaLadovi6pLeKPvCegaT6WHF2YnrA-eT7qw | → | — |
| 32 levels of nesting are accepted | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6ey… | → | a … |
| 33 levels of nesting are refused | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6ey… | → | — |
| duplicate keys: the last value wins, as in JavaScript and Python | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoxLCJhIjoyfQ.N4pPAo5By0BC3mh_2DkTecjzkZxl4nZ9cuzyxtuJqwI | → | a 2 |
More from the author
The secret is bytes (a list of integers 0 to 255, as everywhere in the registry; `utf8Encode` a text secret) and must be at least 32 bytes: RFC 7518 section 3.2 requires an HS256 key at least as long as the hash. A shorter one is refused by both `signJwt` and `verifyJwt`, loudly, since it is a configuration mistake rather than a bad token.
**verifyJwt never throws for a bad token.** It answers `{valid: false, error, message}` with one of these codes, checked in this order:
| error | when | |---|---| | `malformed_token` | not three canonical base64url parts, a header or payload that is not a JSON object, not UTF-8, or JSON the three languages could read differently (below) | | `unsupported_algorithm` | the header's `alg` is not exactly `HS256` (`none`, `RS256`, missing), or it has a `crit` header (RFC 7515 section 4.1.11 requires refusing extensions you do not understand) | | `invalid_signature` | the HMAC-SHA256 of `header.payload` does not match, compared in constant time | | `invalid_claims` | `exp`, `nbf` or `iat` is present but not a number | | `token_expired` | `now >= exp + leeway` (RFC 7519 section 4.1.4: now must be *before* exp) | | `token_not_yet_valid` | `now + leeway < nbf` (section 4.1.5) | | `token_issued_in_future` | `iat > now + leeway` |
The algorithm is checked before any key is used, so the classic attacks (`alg: none`, or an RS256 public key used as an HMAC secret) cannot reach the signature check. `now` is Unix seconds read by the caller; the capability never reads the clock. `exp`, `nbf` and `iat` are each optional here: a token without `exp` never expires, so a caller that requires one should check the claims (`auth.access-token` does). `iss`, `aud`, `sub` and `jti` are returned, not judged.
**signJwt** writes the header `{"alg":"HS256","typ":"JWT"}` and the claims as one canonical JSON text: no whitespace, object keys sorted by code point at every level, non-ASCII written as UTF-8 rather than `\u` escapes, and the usual JSON escapes for quotes, backslashes and control characters. Equal claims therefore sign to byte-identical tokens in every language. Numbers must be whole and within ±(2^53 − 1), the integers JavaScript holds exactly; NumericDates (`exp`, `iat`) are whole seconds anyway. It does not add any claim itself.
**decodeJwt** is for a client reading its own token (to show a name, or to refresh before `exp`). It checks the shape only; anyone can make a token that decodes. Never use it to decide access.
**One reading of JSON.** A token is attacker-controlled input, and three JSON parsers disagree at the edges, so each language refuses the same things: integers beyond 2^53, numbers that overflow to infinity, `NaN`, an escaped lone surrogate (`"\ud800"`), nesting deeper than 32, and anything that is not strict RFC 8259 JSON. A repeated key keeps its last value, as JavaScript and Python both do. Base64url segments must be canonical and unpadded (RFC 7515 section 2).
The RFC 7515 appendix A.1 token (also RFC 7519 section 3.1) is among the vectors, verified and decoded with the RFC's key. Its header has a line break and `typ` first, which is why `signJwt` of the same claims gives a different, canonical token: a JWT is verified as sent, never re-serialised.
Sources: RFC 7519, JSON Web Token (https://www.rfc-editor.org/rfc/rfc7519); RFC 7515, JSON Web Signature, section 7.1 and appendix A.1 (https://www.rfc-editor.org/rfc/rfc7515); RFC 7518, JSON Web Algorithms, section 3.2 (https://www.rfc-editor.org/rfc/rfc7518#section-3.2); RFC 8725, JSON Web Token Best Current Practices, sections 2.1 and 3.1 (https://www.rfc-editor.org/rfc/rfc8725).
Files
| Path | Bytes |
|---|---|
| README.md | 4,170 |
| impl/python/decode_jwt.py | 4,227 |
| impl/python/sign_jwt.py | 2,396 |
| impl/python/verify_jwt.py | 2,679 |
| impl/rust/decode_jwt.rs | 10,817 |
| impl/rust/sign_jwt.rs | 4,201 |
| impl/rust/verify_jwt.rs | 4,554 |
| impl/typescript/decode_jwt.ts | 4,863 |
| impl/typescript/sign_jwt.ts | 3,412 |
| impl/typescript/verify_jwt.ts | 2,879 |
| vectors.json | 23,930 |