Token-based authentication is a way of proving identity in which a user or client signs in once — with a password, a one-time code or a security key — and the server then issues a token that the client presents on every later request instead of the original credentials. An authentication token (often called an auth token or access token) is that issued string: a short-lived, hard-to-forge credential, typically sent in an Authorization: Bearer header, which the server checks in place of the password. Because the token is limited in lifetime and scope, losing one does less damage than losing the password itself.
That short definition hides the decisions that actually make a system safe or unsafe: what the token contains, who checks it and how, where a browser keeps it, when it expires, and whether a thief can replay it. This guide works through those decisions using the IETF OAuth and JOSE specifications, NIST SP 800-63B-4 and the OWASP Cheat Sheet Series, and finishes with a small, tested Node.js JWT verifier that shows each check in code.
Scope: web and API authentication over HTTPS. This is an engineering explainer, not a compliance guide. When you build a real system, follow the current specifications and use a maintained library.
Security token vs access token: two meanings of “token”
The word is overloaded, and most confusion about token-based authentication starts here.
- A security token (a device or app you hold). In NIST’s digital identity guidelines, a token is something you have that proves who you are: a one-time-password generator, a smart card or a cryptographic security key. NIST SP 800-63B-4 now calls these authenticators and notes that earlier editions called them tokens; its glossary entry for “token” simply says “See authenticator.”
- An access token (a credential you are issued). In web and API work, a token is something the server gives you after you have authenticated. RFC 6750 defines a bearer token as one where “any party in possession of the token (a ‘bearer’) can use the token in any way that any other party in possession of it can.”
Both meanings appear in the same login. The security token proves your identity at sign-in; the access token carries the result of that sign-in to every request that follows. When developers say “token-based authentication” they almost always mean the second kind, and that is the subject of the rest of this article. Hardware authenticators get their own short section near the end.
How the flow works
The sequence below shows a typical web or mobile client calling an API. It uses the role names from OAuth 2.0 (RFC 6749): the authorization server authenticates the user and issues tokens; the resource server is the API that accepts them. In a small application both roles can live in the same program — RFC 6749 explicitly allows the authorization server and resource server to be the same server.
User Client Authorization API
(browser/app) server (resource
server)
| | | |
|--credentials->| | |
| (password, |--(1) sign-in request---->| |
| OTP, key) | | check password / OTP / |
| | | security-key signature |
| |<-(2) access token -------| |
| | + refresh token | |
| | (+ ID token, OIDC) | |
| | |
| |--(3) GET /orders Authorization: Bearer <AT> ----->|
| | | (4) validate token: |
| | | verify JWT locally, |
| | |<--- or introspect -------|
| |<-(5) 200 data / 401 invalid_token / 403 scope ------|
| | | |
| | ... access token expires ... |
| |--(6) refresh token------>| |
| |<-(7) new AT + new RT ----| old RT now invalid |
| | | |
|--sign out---->|--(8) revoke token------->| |
- Authenticate once. The user proves identity to the authorization server. How it does that — password, OTP, security key — is deliberately outside OAuth: RFC 6749 says the method is “beyond the scope of this specification.”
- Receive tokens. The server returns an access token, optionally a refresh token, and, if OpenID Connect is in use, an ID token. The next section explains how the three differ.
- Present the access token. RFC 6750 defines the
Authorization: Bearer <token>request header as the method clients should use for bearer tokens. Tokens should not travel in URLs, where they end up in browser history and server logs (RFC 6750 §5.3). - Validate on every request. The API either verifies a self-contained token locally (signature plus claims; see the checklist below) or asks the authorization server whether an opaque token is still active, using token introspection (RFC 7662).
- Answer. RFC 6750 §3.1 defines the error codes: an expired, revoked or malformed token produces
invalid_tokenwith HTTP 401; a valid token that lacks the needed scope producesinsufficient_scopewith HTTP 403. - Refresh when the access token expires, by sending the refresh token to the authorization server — never to the API (RFC 6749 §1.5).
- Rotate. With refresh-token rotation, every refresh returns a new refresh token and invalidates the old one (RFC 9700 §4.14.2).
- Revoke at sign-out through a revocation endpoint (RFC 7009), so the tokens stop working before they would expire on their own.
Example: what the client actually sends
Step 3 on the wire is an ordinary HTTPS request with one extra header. The token below is a synthetic JWT access token; its signature is shortened here, and its decoded contents are shown in the JWT section further down.
GET /v1/orders HTTP/1.1
Host: api.example.test
Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6IjIwMjYtMTAta2V5LTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS50ZXN0Iiwic3ViIjoidXNlci0xODQyIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS50ZXN0IiwiY2xpZW50X2lkIjoib3JkZXJzLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJpYXQiOjE3OTE2MDAwMDAsImV4cCI6MTc5MTYwMDkwMCwianRpIjoiYzNhNWIxZDAtNGY3ZS00YjhlLTlhNTEtNmQyZjBlN2M5YjE0In0.<signature>
Accept: application/json
If the token has expired, the API refuses it with the error format from RFC 6750 §3, and the client then uses its refresh token (step 6):
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="orders-api", error="invalid_token",
error_description="token expired"
Session IDs, self-contained tokens and reference tokens
The popular “JWT vs session” question is really a question about where the state lives. RFC 6749 §1.4 already allows an access token to be either an identifier that points to authorization data held by the server, or a self-contained, signed bundle of that data. Classic web sessions are the first model under another name.
| Server-side session ID | Self-contained token (e.g. signed JWT) | Opaque reference token | |
|---|---|---|---|
| What the client holds | A random identifier, usually in a cookie | The claims themselves plus a signature or MAC | A random identifier |
| Where state lives | Server session store | Inside the token | Authorization server |
| How a service checks it | Looks it up in the session store | Verifies the signature and claims locally, with no network call | Calls the introspection endpoint (RFC 7662), which reports whether the token is active |
| Ending it early | Delete the server-side session | Hard: it stays valid until exp unless every service also checks a deny-list or status list |
Revoke it at the authorization server; the next introspection reports it as inactive |
| Who can read its contents | Nobody — it carries no data | Anyone who holds it, if it is only signed: the payload is encoded, not encrypted | Nobody — it carries no data |
| Typical fit | A single web application with its own server | Many APIs or services that should not each call home on every request | APIs that need immediate revocation, or tokens that would otherwise expose sensitive claims |
OWASP’s JWT cheat sheet makes the trade-off explicit: if you use JWTs as “stateless” sessions you still need a way to invalidate them, and the deny-list that provides it makes the system stateful again. For a single web application, a plain server-side session following OWASP’s session-management guidance is often the simpler answer. Self-contained tokens pay off when several independent services must check the same credential.
Access, refresh and ID tokens
A typical OAuth 2.0 or OpenID Connect deployment issues up to three tokens at once. They have different audiences and must not be used interchangeably.
| Token | What it is for | Sent to | Format | Defined in |
|---|---|---|---|---|
| Access token | Calling a protected API with specific scopes for a limited time | The resource server (API) | Opaque string or a JWT; RFC 9068 profiles the JWT form with typ: at+jwt |
RFC 6749 §1.4 |
| Refresh token | Obtaining new access tokens without asking the user to sign in again | The authorization server only, never the API | Usually opaque | RFC 6749 §1.5 |
| ID token | Telling the client application who signed in, and when | Consumed by the client; its aud must contain the client’s client_id |
Always a JWT | OpenID Connect Core §2 |
Token authentication vs token authorization
Authentication answers “who is this?”; authorization answers “what may they do?”. Token systems split the two across different tokens:
- Authentication happens at sign-in, with the user’s password or authenticator. Its result reaches the client application as an OpenID Connect ID token, which OIDC Core defines as carrying claims “about the Authentication of an End-User.”
- Authorization is what an access token carries. RFC 6749 calls it “a string representing an authorization issued to the client,” with specific scopes and durations. The API decides what to allow from those scopes, not from who the user is alone.
- OAuth 2.0 is an authorization framework. RFC 6749 describes how an application obtains “limited access to an HTTP service”; it does not tell the client who the user is. OpenID Connect adds that identity layer on top. OWASP’s Authentication Cheat Sheet sums it up: use OIDC for authentication and single sign-on, and OAuth for authorization to APIs.
In everyday usage, “token-based authentication” covers both halves: the API authenticates the request by validating the token, then authorizes it from the token’s scopes.
Three more confusions worth clearing up
- JWT is a format, not a protocol. RFC 7519 defines a compact, URL-safe way to represent claims, protected by a JWS signature or MAC (RFC 7515) or by JWE encryption. OAuth access tokens may be JWTs or opaque strings; nothing in OAuth requires JWT.
- An access token is not proof that the user is there. NIST SP 800-63B-4 says a relying party must not, in the absence of other signals, treat an access token as an indicator of the subscriber’s presence, because access and refresh tokens can remain valid long after the user has left. If you need to know who just signed in, validate an ID token; if you need proof the user is present now, reauthenticate.
- Signed does not mean secret. A JWS-protected JWT proves integrity and origin, but its payload is only base64url-encoded, so anyone holding the token can read every claim. OWASP recommends keeping sensitive data server-side behind an opaque reference token, or using JWE when the claims must travel.
What is inside a JWT
The common compact form is three base64url strings joined by dots: header.payload.signature (RFC 7515 §7.1). Decoding the synthetic token from the request example above (made-up values) gives:
Header {"alg":"ES256","kid":"2026-10-key-1","typ":"at+jwt"}
Payload {"iss":"https://auth.example.test",
"sub":"user-1842",
"aud":"https://api.example.test",
"client_id":"orders-web",
"scope":"orders:read",
"iat":1791600000,
"exp":1791600900,
"jti":"c3a5b1d0-4f7e-4b8e-9a51-6d2f0e7c9b14"}
Signature 64 bytes (ECDSA P-256 R||S), base64url-encoded
- Header:
algnames the algorithm (RFC 7515 §4.1.1),kidhints which key was used (§4.1.4), andtypdeclares what kind of JWT it is (§4.1.9). - Registered claims (RFC 7519 §4.1):
ississuer,subsubject,audaudience (a string or an array),expexpiry,nbfnot-before,iatissued-at, andjti, a unique token ID. Times are seconds since 1970-01-01 UTC. - Profile claims: RFC 9068 makes
iss,exp,aud,sub,client_id,iatandjtirequired in JWT access tokens.
Validating a JWT: the checklist
Several of the weaknesses catalogued in RFC 8725 come from verifiers that let the token decide how it should be checked. RFC 8725 (the JWT Best Current Practices) records two classic examples: libraries that accepted "alg":"none" and skipped the signature, and libraries that verified an RS256 token as HS256 using the RSA public key as the HMAC secret. Every item below takes that control away from the token.
- Parse strictly. Exactly three base64url segments, each decoding to a JSON object, as in RFC 7519 §7.2. Reject anything else instead of trying to repair it.
- Pin the algorithms. The application, not the token header, decides which algorithms are acceptable (RFC 8725 §3.1–3.2). Never accept
nonefor a security token; RFC 9068 requires resource servers to reject it for access tokens. - Bind each key to one algorithm. RFC 8725 §3.1 requires each key to be used with exactly one algorithm. If an EC public key is registered only for ES256, a token that claims HS256 with that key’s
kidfails before any cryptography runs. - Choose keys from your own trust store. Treat
kidas untrusted input that only indexes keys you already hold. Do not followjkuorx5uURLs from the token without an allow-list (RFC 8725 §3.10), and confirm that the key belongs to the expected issuer (§3.8). In OAuth deployments the issuer’s keys usually come from its publishedjwks_uri. - Verify the signature over the bytes you received, and compare MACs in constant time (RFC 7518 §3.2).
- Only then read the claims.
issmust exactly match the expected issuer;audmust contain your service’s identifier (RFC 8725 §3.9);expmust be in the future andnbf, if present, in the past. RFC 7519 allows a small leeway for clock skew, “usually no more than a few minutes.” - Check the type when a profile defines one. Explicit typing such as
at+jwtstops one kind of JWT (for example an ID token) from being accepted as another (RFC 8725 §3.11). - Reject unknown critical headers. If
critlists an extension you do not support, the token is invalid (RFC 7515 §4.1.11). - Use strong keys. HMAC keys must be random and at least as long as the hash output — 256 bits for HS256 (RFC 7518 §3.2). Human-memorable passwords must never be used directly as HMAC keys (RFC 8725 §3.5).
- Apply your own rules last: scopes, the subject’s current status, and any deny-list or token status check.
A minimal verifier in Node.js (educational)
The module below implements the checklist for HS256 and ES256 using only node:crypto. It exists to make the checks concrete. Do not use it in production: it ignores JWE, nested JWTs, key rotation from a JWKS endpoint and many other details. Use a maintained, widely reviewed JOSE library and configure it with the same allow-lists.
// jwt-verify.mjs - EDUCATIONAL minimal verifier for compact JWS JWTs (HS256 / ES256).
// Shows the checks RFC 7519, RFC 7515 and RFC 8725 ask for. Production code should
// use a maintained, audited JOSE library instead of this file.
import { createHmac, timingSafeEqual, verify as verifySig } from 'node:crypto';
const B64URL = /^[A-Za-z0-9_-]*$/;
function fail(reason) {
throw new Error('invalid_token: ' + reason);
}
function decodeJson(part, what) {
if (!B64URL.test(part)) fail(what + ' is not base64url');
let v;
try {
v = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(part, 'base64url')));
} catch {
fail(what + ' is not JSON');
}
if (v === null || typeof v !== 'object' || Array.isArray(v)) fail(what + ' not an object');
return v;
}
function checkSignature(alg, key, input, sig) {
if (alg === 'HS256') {
// Only a secret KeyObject of >= 256 bits (RFC 7518 3.2); never a PEM string or public key.
if (key?.type !== 'secret' || key.symmetricKeySize < 32) return false;
const mac = createHmac('sha256', key).update(input).digest();
return sig.length === mac.length && timingSafeEqual(sig, mac);
}
if (alg === 'ES256') {
// JWS carries ECDSA signatures as raw R||S (64 bytes for P-256), not DER.
return sig.length === 64 &&
verifySig('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' }, sig);
}
return false;
}
/**
* @param {string} token compact JWS: header.payload.signature
* @param {object} opts
* keys: { [kid]: { alg: 'HS256' | 'ES256', key: KeyObject } } - trusted, local key store
* algorithms: allow-list, e.g. ['ES256']
* issuer: expected "iss" (exact match)
* audience: this service's identifier; must appear in "aud"
* typ: optional expected "typ" header, e.g. 'at+jwt' (RFC 9068)
* skewSec: clock-skew leeway in seconds (default 60)
* now: current time in seconds (injectable for tests)
* @returns {object} the verified claims
*/
export function verifyJwt(token, opts) {
const { keys, algorithms, issuer, audience, typ, skewSec = 60, now = Date.now() / 1000 } = opts;
// Fail closed on misconfiguration: without these, a token lacking iss/aud would pass.
if (typeof issuer !== 'string' || !issuer || typeof audience !== 'string' || !audience) {
throw new Error('config: issuer and audience are required');
}
if (!Array.isArray(algorithms) || algorithms.length === 0) throw new Error('config: algorithms are required');
if (typeof token !== 'string') fail('not a string');
const parts = token.split('.');
if (parts.length !== 3) fail('not a compact JWS');
const [h, p, s] = parts;
const header = decodeJson(h, 'header');
// 1. Algorithm: "none" is never accepted; anything else must be on the allow-list.
if (header.alg === 'none') fail('alg none');
if (!algorithms.includes(header.alg)) fail('alg not allowed');
if (header.crit !== undefined) fail('unsupported crit header');
// typ is a media type: case-insensitive, "application/" prefix optional (RFC 7515 4.1.9).
const mt = (t) => String(t).toLowerCase().replace(/^application\//, '');
if (typ !== undefined && (typeof header.typ !== 'string' || mt(header.typ) !== mt(typ))) fail('wrong typ');
// 2. Key selection: kid only indexes OUR key store; each key is bound to one algorithm.
const entry = Object.hasOwn(keys, String(header.kid)) ? keys[header.kid] : undefined;
if (!entry) fail('unknown kid');
if (entry.alg !== header.alg) fail('alg does not match key');
// 3. Signature over the exact received bytes "header.payload".
if (!B64URL.test(s)) fail('signature is not base64url');
if (!checkSignature(header.alg, entry.key, h + '.' + p, Buffer.from(s, 'base64url'))) {
fail('bad signature');
}
// 4. Claims - only read AFTER the signature checks out.
const c = decodeJson(p, 'payload');
if (!Number.isFinite(c.exp)) fail('missing exp');
if (now >= c.exp + skewSec) fail('expired');
if (c.nbf !== undefined && (!Number.isFinite(c.nbf) || now + skewSec < c.nbf)) fail('not yet valid');
if (c.iss !== issuer) fail('wrong iss');
const aud = Array.isArray(c.aud) ? c.aud : [c.aud];
if (!aud.includes(audience)) fail('wrong aud');
return c;
}
One detail is easy to miss: JWS carries an ES256 signature as the raw 64-byte R||S pair (RFC 7518 §3.4), not as the DER structure many crypto APIs use by default, hence dsaEncoding: 'ieee-p1363'. The tests generate throwaway keys on every run and cover each rejection path: alg: none, an algorithm outside the allow-list, the HS256-with-public-key confusion attack, unknown key IDs, a tampered payload and a tampered signature, expiry and not-before with and without leeway, wrong issuer or audience, a missing typ where at+jwt is required, malformed input, unknown crit headers, an HMAC key that is not a secret of at least 256 bits, and a verifier configured without an issuer, audience or algorithm list (it refuses to run rather than skip those checks).
// Run: node --test jwt-verify.test.mjs (all keys are synthetic and generated per run)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHmac, createSecretKey, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
import { verifyJwt } from './jwt-verify.mjs';
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const hmacKey = createSecretKey(randomBytes(32));
const ec = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const NOW = 1_800_000_000;
function makeJwt(header, claims, signWith) {
const input = b64(header) + '.' + b64(claims);
let sig = Buffer.alloc(0);
if (signWith === 'hmac') sig = createHmac('sha256', hmacKey).update(input).digest();
if (signWith === 'ec') sig = sign('sha256', Buffer.from(input), { key: ec.privateKey, dsaEncoding: 'ieee-p1363' });
if (Buffer.isBuffer(signWith)) sig = createHmac('sha256', signWith).update(input).digest();
return input + '.' + sig.toString('base64url');
}
const opts = {
keys: { h1: { alg: 'HS256', key: hmacKey }, e1: { alg: 'ES256', key: ec.publicKey } },
algorithms: ['HS256', 'ES256'],
issuer: 'https://as.example.test',
audience: 'https://api.example.test',
now: NOW,
};
const claims = { iss: opts.issuer, aud: opts.audience, sub: 'user-42', exp: NOW + 300 };
const hs = makeJwt({ alg: 'HS256', kid: 'h1' }, claims, 'hmac');
const es = makeJwt({ alg: 'ES256', kid: 'e1' }, claims, 'ec');
const rejects = (token, reason, o = opts) =>
assert.throws(() => verifyJwt(token, o), new RegExp('invalid_token: ' + reason));
test('valid HS256 and ES256 tokens are accepted', () => {
assert.equal(verifyJwt(hs, opts).sub, 'user-42');
assert.equal(verifyJwt(es, opts).sub, 'user-42');
const multiAud = makeJwt({ alg: 'ES256', kid: 'e1' }, { ...claims, aud: ['x', opts.audience] }, 'ec');
assert.equal(verifyJwt(multiAud, opts).sub, 'user-42');
});
test('alg "none" is rejected even if a caller lists it', () => {
const unsigned = makeJwt({ alg: 'none', kid: 'h1' }, claims, null);
rejects(unsigned, 'alg none', { ...opts, algorithms: ['none', 'HS256'] });
});
test('algorithm outside the allow-list is rejected', () => {
rejects(es, 'alg not allowed', { ...opts, algorithms: ['HS256'] });
});
test('HS256 forged with the public key (alg confusion) is rejected', () => {
const pem = Buffer.from(ec.publicKey.export({ type: 'spki', format: 'pem' }));
rejects(makeJwt({ alg: 'HS256', kid: 'e1' }, claims, pem), 'alg does not match key');
});
test('unknown or missing kid is rejected', () => {
rejects(makeJwt({ alg: 'HS256', kid: 'nope' }, claims, 'hmac'), 'unknown kid');
rejects(makeJwt({ alg: 'HS256' }, claims, 'hmac'), 'unknown kid');
rejects(makeJwt({ alg: 'HS256', kid: '__proto__' }, claims, 'hmac'), 'unknown kid');
});
test('tampered payload or signature is rejected', () => {
for (const t of [hs, es]) {
const [h, , s] = t.split('.');
rejects([h, b64({ ...claims, sub: 'admin' }), s].join('.'), 'bad signature');
const flipped = Buffer.from(s, 'base64url');
flipped[0] ^= 1;
rejects([h, t.split('.')[1], flipped.toString('base64url')].join('.'), 'bad signature');
}
rejects(hs.slice(0, hs.lastIndexOf('.') + 1), 'bad signature'); // signature stripped
});
test('exp: enforced with clock-skew leeway', () => {
const t = makeJwt({ alg: 'HS256', kid: 'h1' }, { ...claims, exp: NOW - 30 }, 'hmac');
assert.ok(verifyJwt(t, opts)); // 30 s late, inside the 60 s leeway
rejects(t, 'expired', { ...opts, skewSec: 0 });
rejects(makeJwt({ alg: 'HS256', kid: 'h1' }, { ...claims, exp: undefined }, 'hmac'), 'missing exp');
});
test('nbf: future tokens rejected beyond the leeway', () => {
const soon = makeJwt({ alg: 'HS256', kid: 'h1' }, { ...claims, nbf: NOW + 30 }, 'hmac');
assert.ok(verifyJwt(soon, opts));
rejects(makeJwt({ alg: 'HS256', kid: 'h1' }, { ...claims, nbf: NOW + 600 }, 'hmac'), 'not yet valid');
});
test('wrong issuer or audience is rejected', () => {
rejects(makeJwt({ alg: 'HS256', kid: 'h1' }, { ...claims, iss: 'https://evil.test' }, 'hmac'), 'wrong iss');
rejects(makeJwt({ alg: 'HS256', kid: 'h1' }, { ...claims, aud: 'https://other.test' }, 'hmac'), 'wrong aud');
rejects(makeJwt({ alg: 'HS256', kid: 'h1' }, { ...claims, aud: undefined }, 'hmac'), 'wrong aud');
});
test('typ is enforced when the profile requires it (RFC 9068 at+jwt)', () => {
const at = makeJwt({ alg: 'ES256', kid: 'e1', typ: 'at+jwt' }, claims, 'ec');
assert.ok(verifyJwt(at, { ...opts, typ: 'at+jwt' }));
const atLong = makeJwt({ alg: 'ES256', kid: 'e1', typ: 'application/at+jwt' }, claims, 'ec');
assert.ok(verifyJwt(atLong, { ...opts, typ: 'at+jwt' })); // RFC 9068 sec. 4 allows both forms
rejects(es, 'wrong typ', { ...opts, typ: 'at+jwt' });
rejects(makeJwt({ alg: 'ES256', kid: 'e1', typ: 'JWT' }, claims, 'ec'), 'wrong typ', { ...opts, typ: 'at+jwt' });
});
test('malformed input and crit headers are rejected', () => {
rejects('abc.def', 'not a compact JWS');
rejects('a.b.c.d', 'not a compact JWS');
rejects('e30=.e30.', 'header is not base64url');
rejects(b64({ alg: 'HS256', kid: 'h1' }) + '.e30.%%%', 'signature is not base64url');
rejects(makeJwt({ alg: 'HS256', kid: 'h1', crit: ['x'], x: 1 }, claims, 'hmac'), 'unsupported crit');
rejects(makeJwt({ alg: 'HS256', kid: 'h1' }, [1, 2], 'hmac'), 'payload not an object');
rejects(b64(null) + '.e30.', 'header not an object');
});
test('missing issuer or audience in the options fails closed', () => {
const noIssAud = makeJwt({ alg: 'HS256', kid: 'h1' }, { sub: 'user-42', exp: NOW + 300 }, 'hmac');
assert.throws(() => verifyJwt(noIssAud, { ...opts, issuer: undefined }), /config: issuer and audience/);
assert.throws(() => verifyJwt(noIssAud, { ...opts, audience: undefined }), /config: issuer and audience/);
assert.throws(() => verifyJwt(noIssAud, { ...opts, issuer: '', audience: '' }), /config: issuer and audience/);
assert.throws(() => verifyJwt(hs, { ...opts, algorithms: [] }), /config: algorithms/);
});
test('HMAC key must be a secret KeyObject of at least 256 bits', () => {
const pem = ec.publicKey.export({ type: 'spki', format: 'pem' });
rejects(makeJwt({ alg: 'HS256', kid: 'p' }, claims, Buffer.from(pem)), 'bad signature', { ...opts, keys: { p: { alg: 'HS256', key: pem } } });
const weak = createSecretKey(Buffer.from('k'));
rejects(makeJwt({ alg: 'HS256', kid: 'w' }, claims, Buffer.from('k')), 'bad signature', { ...opts, keys: { w: { alg: 'HS256', key: weak } } });
});
test('exp must be a finite number', () => {
const inf = b64({ alg: 'HS256', kid: 'h1' }) + '.' + Buffer.from('{"iss":"' + opts.issuer + '","aud":"' + opts.audience + '","exp":1e400}').toString('base64url');
rejects(inf + '.' + createHmac('sha256', hmacKey).update(inf).digest('base64url'), 'missing exp');
});
Run them with node --test jwt-verify.test.mjs.
Where a browser should keep tokens
Every storage option in a browser trades one risk for another. Cookies are sent automatically, which opens the door to cross-site request forgery; Web Storage is readable by any script on the page, which makes cross-site scripting (XSS) a direct path to token theft.
| Option | Main exposure | What the guidance says |
|---|---|---|
Cookie with Secure; HttpOnly; SameSite |
CSRF, because the browser attaches the cookie automatically; script cannot read it | NIST SP 800-63B-4 requires session cookies to be HTTPS-only and recommends HttpOnly, the __Host- prefix and SameSite=Lax or Strict. OWASP treats SameSite as defence in depth, not a replacement for CSRF tokens. RFC 6750 requires CSRF precautions whenever bearer tokens are kept in cookies. |
localStorage / sessionStorage |
Any XSS can read and exfiltrate every token; localStorage also persists across sessions |
OWASP’s Session Management Cheat Sheet says not to store tokens, session IDs, JWTs or refresh tokens there. |
| Backend-for-Frontend (BFF) | The browser holds only a session cookie; OAuth tokens never reach page scripts | OWASP names it as the alternative to cookie-held tokens. The IETF draft on OAuth for browser-based applications describes a BFF that acts as a confidential client, keeps access and refresh tokens server-side and forwards API calls. |
Note what HttpOnly does not do: OWASP points out that XSS can still send requests that carry the cookie, even though it cannot read the value. Preventing XSS remains the real defence; storage choices only limit how far a successful attack reaches.
Expiry, rotation and revocation
- Keep access tokens short-lived. RFC 6750 recommends bearer tokens with a lifetime of one hour or less, especially for clients running in a browser. Short lifetimes are what make self-contained tokens tolerable, because they bound how long a leaked token stays useful.
- Protect refresh tokens harder. They live longer and can mint new access tokens. RFC 9700 (the OAuth 2.0 Security Best Current Practice, January 2025) requires refresh tokens for public clients, such as single-page and mobile apps, to be either sender-constrained or rotated.
- Rotation gives you theft detection. If an attacker and the legitimate client both use the same refresh token, one of them will present an already-invalidated token. The authorization server cannot tell which party is which, so RFC 9700 has it revoke the active token, forcing a fresh sign-in.
- Expire idle refresh tokens. RFC 9700 says refresh tokens should expire after a period of inactivity, and may be revoked automatically on events such as a password change or logout.
- Revoke on sign-out. RFC 7009 defines a revocation endpoint. It answers HTTP 200 even for a token that was already invalid, because the goal — that token no longer working — is met either way, and the server may also invalidate other tokens issued under the same grant.
- Remember that revocation does not reach self-contained tokens. An API that verifies JWTs locally keeps accepting a revoked token until
exp, unless it also checks a deny-list keyed onissandjti(OWASP warns against keying it on a hash of the raw token, which malleable encodings can bypass) or uses introspection. - For session IDs: issue a new session ID whenever privileges change, above all at login, to prevent session fixation, and invalidate the session on the server at logout rather than relying on the browser to discard the cookie (OWASP Session Management Cheat Sheet).
Bearer tokens versus sender-constrained tokens
A bearer token works for whoever holds it. RFC 6750 §5.1 lists the threats this creates, and each has a standard countermeasure:
- Manufacture or modification — an attacker forges a token or edits its claims, for example to extend its validity. Countered by the signature or MAC and the validation checklist above.
- Disclosure — the token, or sensitive claims inside it, leak in transit. Countered by TLS, with certificate validation, and by keeping sensitive data out of readable tokens.
- Redirect — a token meant for one API is replayed at another. Countered by audience restriction: issue tokens with
audand check it. - Replay — a captured token is reused. Countered by short lifetimes and, more strongly, by binding the token to its legitimate holder.
That binding is called sender-constraining, and RFC 9700 §2.2.1 recommends it for access tokens. Two standards exist:
- Mutual-TLS certificate-bound tokens (RFC 8705). The client authenticates with an X.509 certificate during the TLS handshake; the token carries the certificate’s SHA-256 thumbprint in a
cnfclaim (x5t#S256), and the API accepts it only on a connection authenticated with that same certificate. - DPoP (RFC 9449). An application-layer alternative suited to browsers and mobile apps. For every request the client signs a short JWT — typed
dpop+jwtand containing the HTTP method (htm), target URI (htu), a timestamp, a uniquejtiand, when calling an API, a hash of the access token (ath) — with a private key it holds. The access token is bound to the thumbprint of the matching public key, and requests use theAuthorization: DPoPscheme. RFC 9449 notes that DPoP exists partly because TLS client certificates are impractical for single-page browser applications.
Sender-constraining limits what a stolen token is worth; it does not stop tokens from leaking. OWASP is explicit that keys and tokens still need protecting.
Where hardware security keys fit
A FIDO security key is an authenticator in NIST’s sense: it strengthens the first step of the flow, the sign-in, and says nothing about the token issued afterwards. FIDO2 combines the W3C Web Authentication (WebAuthn) API with the FIDO Client to Authenticator Protocol (CTAP), which can reach a key over USB HID, NFC or Bluetooth. Because each WebAuthn credential is scoped to a single relying party, and the key’s signed response covers a hash of that party’s identifier as well as the server’s challenge, a look-alike phishing site cannot reuse the response. NIST SP 800-63B-4 classes this kind of cryptographic authentication as phishing-resistant and states that OTP codes typed in by hand are not. Once the server has issued an access token, though, that token is a bearer credential like any other, which is why short lifetimes, careful storage and sender-constraining still matter.
Frequently asked questions
What is token-based authentication in simple terms?
You log in once, the server gives your app a token, and the app shows that token on each later request instead of your password. The server trusts the token for a limited time and for limited actions.
What is a token in authentication?
It depends on context. In identity guidelines such as NIST SP 800-63B, a “security token” is a device or app that proves who you are, such as an OTP generator or a FIDO security key. In web and API development, an authentication token is the credential a server issues after sign-in, which the client then sends with each request.
What is the difference between token authentication and token authorization?
Authentication establishes who the user is, at sign-in; an OpenID Connect ID token reports that result to the application. Authorization decides what a request may do; an OAuth access token carries that permission as scopes for a specific API.
Is JWT the same as OAuth?
No. OAuth 2.0 (RFC 6749) is a framework for delegating access to APIs. JWT (RFC 7519) is a token format. OAuth access tokens can be JWTs, but they can equally be opaque strings, and JWTs are used in many places that have nothing to do with OAuth.
JWT vs session: which should I use?
For a single web application with its own server, a server-side session in a Secure; HttpOnly; SameSite cookie is usually simpler and easier to revoke. Self-contained JWTs make sense when several independent services must verify the same credential without calling a central store. If you choose JWTs for user sessions, plan how you will invalidate them before they expire.
Where should I store a JWT in the browser?
OWASP advises against localStorage and sessionStorage, because any XSS can read them. Prefer an HttpOnly, Secure, SameSite cookie with CSRF protection, or a Backend-for-Frontend that keeps the tokens on the server.
How do you revoke a JWT before it expires?
You cannot change a token that is already out in the world, so the services that accept it must check something: a deny-list keyed on iss and jti, a token status list, or the authorization server through introspection (RFC 7662). Keeping access-token lifetimes short limits how long revocation has to cover.
What is the difference between an access token and a refresh token?
An access token is sent to the API on every call and should be short-lived. A refresh token is sent only to the authorization server, to obtain new access tokens; it lives longer and should be rotated or sender-constrained.
Is token-based authentication more secure than passwords?
It complements the login method rather than replacing it. The user still authenticates with a password, a passkey or another authenticator; tokens then keep that primary credential off every later request and can be narrowed in scope, audience and lifetime. A stolen bearer token is still dangerous until it expires or is revoked.
Related reading on Ambimat
- USB HID (Human Interface Device) explained — the device class that CTAP uses for USB security keys.
- CyberChef — includes JWT decode, sign and verify operations that are useful for inspecting test tokens. Never paste production tokens into any web tool.
- AmbiSecure OnePass USB Key – product page for the AmbiSecure FIDO2 hardware security key.
Sources
- IETF, D. Hardt (ed.), RFC 6749: The OAuth 2.0 Authorization Framework, October 2012, §§1.1, 1.4, 1.5, 3.1. rfc-editor.org
- IETF, M. Jones, D. Hardt, RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage, October 2012, §§1.2, 2.1, 3.1, 5.1–5.3. rfc-editor.org
- IETF, T. Lodderstedt, S. Dronia, M. Scurtescu, RFC 7009: OAuth 2.0 Token Revocation, August 2013, §2. rfc-editor.org
- IETF, M. Jones, J. Bradley, N. Sakimura, RFC 7515: JSON Web Signature (JWS), May 2015, §§4.1.1, 4.1.4, 4.1.9, 4.1.11, 7.1. rfc-editor.org
- IETF, M. Jones, RFC 7518: JSON Web Algorithms (JWA), May 2015, §§3.2, 3.4. rfc-editor.org
- IETF, M. Jones, J. Bradley, N. Sakimura, RFC 7519: JSON Web Token (JWT), May 2015, §§2, 4.1, 7.2. rfc-editor.org
- IETF, J. Richer (ed.), RFC 7662: OAuth 2.0 Token Introspection, October 2015, §2. rfc-editor.org
- IETF, B. Campbell, J. Bradley, N. Sakimura, T. Lodderstedt, RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens, February 2020, §3. rfc-editor.org
- IETF, Y. Sheffer, D. Hardt, M. Jones, RFC 8725: JSON Web Token Best Current Practices, February 2020, §§2.1, 3.1–3.11. rfc-editor.org
- IETF, V. Bertocci, RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens, October 2021, §§2.2, 4. rfc-editor.org
- IETF, D. Fett, B. Campbell, J. Bradley, T. Lodderstedt, M. Jones, D. Waite, RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP), September 2023, §§1, 4.2, 6.1, 7.1. rfc-editor.org
- IETF, T. Lodderstedt, J. Bradley, A. Labunets, D. Fett, RFC 9700: Best Current Practice for OAuth 2.0 Security, January 2025, §§2.2, 4.14.2. rfc-editor.org
- IETF OAuth Working Group, A. Parecki, P. De Ryck, D. Waite, OAuth 2.0 for Browser-Based Applications, Internet-Draft draft-ietf-oauth-browser-based-apps-27 (work in progress), July 2026, §6.1. datatracker.ietf.org
- OpenID Foundation, N. Sakimura et al., OpenID Connect Core 1.0 incorporating errata set 2, 15 December 2023, §2 (ID Token). openid.net
- NIST, D. Temoshok et al., SP 800-63B-4: Digital Identity Guidelines – Authentication and Authenticator Management, August 2025, §1 Introduction, §3.1 authenticator types, §3.2.5 Phishing Resistance, §5 Session Management (Browser Cookies; Access Tokens), Glossary “token”. pages.nist.gov
- OWASP Cheat Sheet Series, Session Management Cheat Sheet, retrieved 10 October 2026, sections Cookies, HTML5 Web Storage API, Renew the Session ID After Any Privilege Level Change, Session Expiration. cheatsheetseries.owasp.org
- OWASP Cheat Sheet Series, JSON Web Token Cheat Sheet, retrieved 10 October 2026, sections Not using JWTs, Threats on JWTs, Replay protection, Token Confidentiality and JWE. cheatsheetseries.owasp.org
- OWASP Cheat Sheet Series, REST Security Cheat Sheet, retrieved 10 October 2026, section JWT. cheatsheetseries.owasp.org
- OWASP Cheat Sheet Series, Authentication Cheat Sheet, retrieved 10 October 2026, sections OpenID Connect (OIDC), FIDO. cheatsheetseries.owasp.org
- W3C, Web Authentication: An API for accessing Public Key Credentials – Level 3, W3C Recommendation, 25 August 2026, Abstract and §6.1 Authenticator Data. w3.org
- FIDO Alliance, Client to Authenticator Protocol (CTAP) v2.2, Proposed Standard, 14 July 2025, §11 transport bindings (§11.2 USB HID). fidoalliance.org
- Node.js project, Crypto module documentation (
crypto.verifydsaEncoding,crypto.timingSafeEqual). nodejs.org
Written by the Ambimat engineering team. Code example tested on Node.js 25.8.2.