How should an API authenticate and authorize requests?
There is no single best way to authenticate an API; there is a best fit for each kind of caller. For your own web app, a server-side session is the usual fit and it can be revoked at once. For other applications acting for a user, use OAuth 2.0 access tokens: opaque if you must cut access off immediately, JWT if you must avoid a lookup on every request and can live with a revocation window. An API key identifies an application, not a user. Whichever you pick, deciding what the caller may do is a separate job that your code has to do on every request.
The decision
You are building an HTTP API and need to decide how it knows who is calling and what that caller may do. The usual vocabulary mixes things that are not alternatives, so the first step is to separate them. Then four ways of recognizing a caller are compared on six criteria, with each claim marked by how it is backed.
First, what is not an option
- Authentication and authorization are different questions. Authentication asks who is calling. Authorization asks what that caller may do. OWASP lists broken object level authorization first in its 2023 API Security Top 10 and broken authentication second.
- OAuth 2.0 is a framework, not a login method. RFC 6749 defines it as a way for an application to obtain limited access; the access token is a credential for protected resources. Opaque tokens and JWT access tokens below are two forms of the same OAuth 2.0 token.
- OpenID Connect is a layer on top of OAuth 2.0 that lets a client confirm the identity of a user from the authentication an authorization server performed. It answers "who signed in to this client", which is a different question from "how does my API check a request". It is not in the table.
- JWT is a token format, not a method. It can carry an OAuth 2.0 access token (RFC 9068 profiles that use), so it appears below as one form of access token.
- Out of scope: service identities such as mTLS, and sender-constrained tokens. We did not evaluate them.
Options
- Server-side session. Your server keeps the session; the client holds only an identifier, usually in a cookie.
- Opaque access token. An OAuth 2.0 authorization server issues a random string; the API asks the server whether it is still valid.
- JWT access token. An OAuth 2.0 authorization server issues a signed token; the API verifies it locally.
- API key. A long random string that identifies a client application; you store it hashed and look it up.
The criteria, and why each matters:
- Revocation. How soon can you cut off a credential after an account is disabled or a secret leaks?
- Cost of each check. Whether every request needs a call to shared state, which affects latency and what must stay available.
- Impact of a leak. What an attacker gets by holding a copy, and what limits it.
- First-party web app. Your own browser-based app calling your own API.
- Delegated third-party access. A user lets another application act for them with limited access.
- Server-to-server. A program with no user present calls the API.
Comparison
The labels mean: Spec, stated by a standard or by OWASP guidance, with the source linked; Experiment, shown by the local simulation below; Analysis, our reasoning from those facts, not tested; Not verified, a question we did not evaluate. There are no scores or rankings, because we have no basis for the numbers. Each criterion is a short list, one line per option.
Revocation
- Server-side session. Deleting the server-side session makes the next request fail. [Experiment] Experiment 1 in this note · checked 2026-10-21
- Opaque access token. Introspection returns active=false once the token is revoked, so the next check fails. [Spec] RFC 7662 §2.2 · checked 2026-10-21
- JWT access token. A signed, unexpired token passes local checks after the account is revoked, until exp. A denylist restores revocation but adds a lookup per request. [Experiment] Experiment 1 in this note · checked 2026-10-21
- API key. Deleting the key from the table stops it at once. It has no expiry unless you build one. [Experiment] Experiment 3 in this note · checked 2026-10-21
Cost of each check
- Server-side session. One lookup in the session store per request. [Analysis] checked 2026-10-21
- Opaque access token. One call to the authorization server per check unless you cache the answer; a cache delays revocation by its lifetime. [Analysis] RFC 7662 §1 · checked 2026-10-21
- JWT access token. Verified locally with a key: no call to shared state in our simulation (0 lookups; the session, introspection and denylist checks made 1 each). [Experiment] Experiment 1 in this note · checked 2026-10-21
- API key. One lookup per request against the stored hash. [Analysis] checked 2026-10-21
Impact of a leak
- Server-side session. Whoever holds the session identifier can act as the user until the session ends. The Secure, HttpOnly and SameSite cookie attributes reduce how easily it leaks. [Spec] OWASP Session Management Cheat Sheet · checked 2026-10-21
- Opaque access token. A bearer token: any party in possession can use it. Limit the damage with a short lifetime, an audience and a narrow scope. [Spec] RFC 6750 §1.2 and §5.2 · checked 2026-10-21
- JWT access token. A bearer token like any other: any party in possession can use it. See Revocation for how long a copy stays usable after the token is revoked. [Spec] RFC 6750 §1.2 · checked 2026-10-21
- API key. A leaked key works from anywhere, at any time, until you delete it. [Experiment] Experiment 3 in this note · checked 2026-10-21
First-party web app
- Server-side session. The usual fit: an HttpOnly cookie keeps the identifier away from page scripts, and the meaning of the session stays on the server. [Spec] OWASP Session Management Cheat Sheet · checked 2026-10-21
- Opaque access token. Possible, but the browser needs somewhere to keep the token. We did not evaluate the storage options. [Not verified] checked 2026-10-21
- JWT access token. Possible, with the same storage question as the opaque token. We did not evaluate it. [Not verified] checked 2026-10-21
- API key. Not suited: OWASP says API keys should not be used for user authentication. [Spec] OWASP API Security 2023, API2 Broken Authentication · checked 2026-10-21
Delegated third-party access
- Server-side session. Not designed for it: a third party would need the user's own session. [Analysis] checked 2026-10-21
- Opaque access token. This is what OAuth 2.0 is for: a third party obtains limited access, expressed as scopes and durations. [Spec] RFC 6749 §1.4 · checked 2026-10-21
- JWT access token. Same OAuth 2.0 use as the opaque token. RFC 9068 profiles the JWT form and requires the API to check typ, audience and exp. [Spec] RFC 9068 §4 · checked 2026-10-21
- API key. A key identifies the calling application, not a user who delegated access. [Spec] OWASP API Security 2023, API2 Broken Authentication · checked 2026-10-21
Server-to-server
- Server-side session. Poor fit: there is no user to sign in and no browser to hold a cookie. [Analysis] checked 2026-10-21
- Opaque access token. OAuth 2.0 defines a client credentials grant for a client acting on resources under its own control. We did not test it here. [Spec] RFC 6749 §4.4 · checked 2026-10-21
- JWT access token. Same client credentials grant as the opaque token, with the token verified locally. We did not test it here. [Spec] RFC 6749 §4.4 · checked 2026-10-21
- API key. OWASP says API keys should be used only for API client authentication, which is this case. [Spec] OWASP API Security 2023, API2 Broken Authentication · checked 2026-10-21
Recommended by scenario
These are starting points under stated assumptions, not rules.
Your own web app calls your own API, and you run both.
Consider: Server-side session.
Assumes:
- The app and the API are served so that cookies reach both.
- You can keep a session store available.
Limits:
- Does not cover native apps, third-party access or other services calling the API.
- We did not compare token storage in the browser.
Other applications need limited access on a user's behalf.
Consider: Opaque access token; JWT access token.
Assumes:
- You use or can run an OAuth 2.0 authorization server.
- Tokens are short-lived and carry an audience and narrow scopes.
Limits:
- Choose opaque if cutting access off at once matters more than avoiding a call per check; choose JWT if the reverse, and accept that revocation waits for exp or needs a denylist.
- Refresh tokens, DPoP and mTLS were not evaluated.
Many services must validate tokens and each cannot call the authorization server per request.
Consider: JWT access token.
Assumes:
- A short lifetime is acceptable as your revocation window, or you accept a denylist lookup.
- Every service validates typ, issuer, audience and exp.
Limits:
- We measured lookup counts in a simulation, not latency or throughput.
- Key rotation and distribution were not tested.
A program with no user calls your API.
Consider: API key; Opaque access token; JWT access token.
Assumes:
- A key suffices if you own both ends and can rotate and delete keys.
- Use the token options if you need expiry and scopes, via the client credentials grant.
Limits:
- The client credentials grant was not tested here.
- Keys stored in code or logs are a separate risk this note does not cover.
Trade-offs and risks
- Revocation against lookups is the central trade-off between the two token forms. The opaque token buys immediate revocation with a call to the authorization server per check (or a cache that delays revocation). The JWT removes that call and gives up immediate revocation. A denylist gives revocation back and brings a lookup back, which is the opaque token's cost.
- Every option is a bearer credential. RFC 6750 says any party in possession of the token can use it, and asks for limited lifetimes and an intended audience. A session cookie or API key behaves the same way when copied.
- Audience checks are easy to skip. RFC 9068 and RFC 8725 require the receiver to check the audience; experiment 2 shows what accepting a token meant for another API looks like.
- Passing authentication does not decide access. See experiment 4.
Evidence: four experiments
The code below simulates the resource server's decision for each option in memory. It makes no network calls and uses made-up users and tokens. It shows how the logic behaves, not how any product behaves, and the cost it counts is calls to shared state, not time.
Experiment 1: revocation and the cost of a check
Everything issued to the user is revoked, then each credential is presented. The plain JWT check never consults shared state, so it still passes; after its expiry time it is rejected.
== 1. Revocation: everything revoked at t=60, presented at t=61 ==
session cookie rejected (unknown session)
opaque token (introspection) rejected (inactive)
JWT, signature/exp/aud only ACCEPTED
JWT + denylist lookup rejected (revoked (denylist))
lookups in shared state: 3 (session 1, introspection 1, denylist 1; plain JWT 0)
same JWT at t=3601 (after exp) rejected (expired)Experiment 2: audience
A token issued for one API is presented to another. It is rejected only if the receiver checks the audience.
== 2. Audience: a token for orders-api sent to billing-api ==
aud checked rejected (wrong audience)
aud NOT checked (the mistake) ACCEPTEDExperiment 3: an API key that leaks
The key has no expiry, so a copy works at any time until the key is deleted.
== 3. API key: a leaked key, used at any time ==
key at t=10 ACCEPTED
same key at t=100000000 ACCEPTED
after the key is deleted rejected (unknown key)Experiment 4: authenticated is not authorized
The user is authenticated in every line. Only the checks in the handler decide what they can read. OWASP says comparing the session's user ID with the object's ID is not a sufficient fix on its own; this handler compares the owner of the record, and a real API needs that check at every endpoint that takes an object ID.
== 4. Authenticated is not authorized: GET /orders/:id ==
alice reads bob's order, authentication only:
{"status":200,"body":{"owner":"bob","item":"monitor"}}
alice reads bob's order, ownership checked:
{"status":403,"why":"not your order"}
alice reads her own order, ownership checked:
{"status":200,"body":{"owner":"alice","item":"keyboard"}}
token without the orders:read scope:
{"status":403,"why":"missing scope"}When to reconsider
- Your revocation requirement changes, for example a fixed maximum time between disabling an account and losing access.
- The API moves from one service to many, or from first-party to third-party clients.
- You adopt sender-constrained tokens (DPoP or mTLS), which change the leak column and which this note did not evaluate.
- The cited specifications are revised or replaced; the cells were last checked on 2026-10-21.
What we did not verify
- Any real authorization server, identity provider, or library. The simulation implements only the checks shown. It signs with HS256 for brevity; deployments with several services commonly use asymmetric keys, which we did not test.
- Latency or throughput. The only cost we measured is the number of calls to shared state in the simulation.
- Refresh tokens, the individual OAuth 2.0 grants including client credentials, PKCE, DPoP, mTLS, multi-factor authentication, multi-tenant isolation, and where a browser should keep a token.
- Whether your threat model, regulations, or traffic match the assumptions in each scenario.
- Cells marked Analysis follow from the cited facts but were not tested. Cells marked Not verified are open questions.
Independence. This guide recommends no product or vendor and carries no sponsorship. Small Tools, our own product, is mentioned only under related tools and did not influence the comparison.
Reproduce it
Save the two files in one directory and run node demo.mjs (Node.js v24.7.0 was used). The output above is that run.
credentials.mjs
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
const b64u = (x) => Buffer.from(x).toString("base64url");
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
// One authorization server / session store, and four ways a resource server can decide "who is calling".
// Time is a number of seconds passed in by the caller, so nothing here waits or uses a real clock.
export class World {
constructor() {
this.sessions = new Map(); // session id -> { sub }
this.opaque = new Map(); // opaque token -> { sub, scope, aud, exp, active }
this.apiKeys = new Map(); // sha256(key) -> { client }
this.revokedJti = new Set(); // optional denylist for JWTs
this.secret = randomBytes(32);
this.lookups = 0; // calls from the resource server to shared state (database, introspection endpoint, denylist)
}
// 1. Server-side session: the cookie value is only an identifier.
login(sub) { const sid = randomBytes(16).toString("hex"); this.sessions.set(sid, { sub }); return sid; }
checkSession(sid) { this.lookups++; const s = this.sessions.get(sid); return s ? { ok: true, sub: s.sub } : { ok: false, why: "unknown session" }; }
// 2. Opaque access token + introspection (RFC 7662): the resource server asks the authorization server every time.
issueOpaque({ sub, scope, aud, now, ttl = 3600 }) { const t = randomBytes(16).toString("hex"); this.opaque.set(t, { sub, scope, aud, exp: now + ttl, active: true }); return t; }
introspect(token, now) {
this.lookups++;
const r = this.opaque.get(token);
return r && r.active && now < r.exp ? { active: true, sub: r.sub, scope: r.scope, aud: r.aud } : { active: false };
}
checkOpaque(token, now, aud) {
const r = this.introspect(token, now);
if (!r.active) return { ok: false, why: "inactive" };
if (!r.aud.includes(aud)) return { ok: false, why: "wrong audience" };
return { ok: true, sub: r.sub, scope: r.scope };
}
// 3. Self-contained JWT access token (RFC 9068 shape). HS256 keeps the demo short; real deployments usually sign with an asymmetric key.
issueJwt({ sub, scope, aud, now, ttl = 3600 }) {
const header = b64u(JSON.stringify({ alg: "HS256", typ: "at+jwt" }));
const payload = b64u(JSON.stringify({ iss: "https://auth.example", sub, scope, aud, iat: now, exp: now + ttl, jti: randomBytes(8).toString("hex") }));
const sig = createHmac("sha256", this.secret).update(`${header}.${payload}`).digest("base64url");
return `${header}.${payload}.${sig}`;
}
// Local verification: no call to the authorization server. Options let the demo switch one check off at a time.
checkJwt(token, now, aud, { checkAud = true, useDenylist = false } = {}) {
const [h, p, s] = token.split(".");
if (!h || !p || !s) return { ok: false, why: "malformed" };
const want = createHmac("sha256", this.secret).update(`${h}.${p}`).digest();
const got = Buffer.from(s, "base64url");
if (got.length !== want.length || !timingSafeEqual(got, want)) return { ok: false, why: "bad signature" };
const header = JSON.parse(Buffer.from(h, "base64url"));
const c = JSON.parse(Buffer.from(p, "base64url"));
if (header.alg !== "HS256" || header.typ !== "at+jwt") return { ok: false, why: "wrong alg/typ" };
if (c.iss !== "https://auth.example") return { ok: false, why: "wrong issuer" };
if (checkAud && !c.aud.includes(aud)) return { ok: false, why: "wrong audience" };
if (!(now < c.exp)) return { ok: false, why: "expired" };
if (useDenylist) { this.lookups++; if (this.revokedJti.has(c.jti)) return { ok: false, why: "revoked (denylist)" }; }
return { ok: true, sub: c.sub, scope: c.scope, jti: c.jti };
}
// 4. API key: a long random string that identifies a client application. Stored hashed; no expiry unless one is built.
createApiKey(client) { const k = `key_${randomBytes(16).toString("hex")}`; this.apiKeys.set(sha256(k), { client }); return k; }
checkApiKey(key) { this.lookups++; const r = this.apiKeys.get(sha256(key)); return r ? { ok: true, client: r.client } : { ok: false, why: "unknown key" }; }
}demo.mjs
import { World } from "./credentials.mjs";
const w = new World();
const show = (label, r) => console.log(`${label.padEnd(31)} ${r.ok ? "ACCEPTED" : "rejected"}${r.why ? ` (${r.why})` : ""}`);
console.log("== 1. Revocation: everything revoked at t=60, presented at t=61 ==");
const sid = w.login("alice");
const op = w.issueOpaque({ sub: "alice", scope: "orders:read", aud: ["orders-api"], now: 0 });
const jwt = w.issueJwt({ sub: "alice", scope: "orders:read", aud: ["orders-api"], now: 0 });
const jwt2 = w.issueJwt({ sub: "alice", scope: "orders:read", aud: ["orders-api"], now: 0 });
// revoke everything alice has
w.sessions.delete(sid);
w.opaque.get(op).active = false;
w.revokedJti.add(JSON.parse(Buffer.from(jwt2.split(".")[1], "base64url")).jti);
w.lookups = 0;
show("session cookie", w.checkSession(sid));
show("opaque token (introspection)", w.checkOpaque(op, 61, "orders-api"));
show("JWT, signature/exp/aud only", w.checkJwt(jwt, 61, "orders-api"));
show("JWT + denylist lookup", w.checkJwt(jwt2, 61, "orders-api", { useDenylist: true }));
console.log(`lookups in shared state: ${w.lookups} (session 1, introspection 1, denylist 1; plain JWT 0)`);
show("same JWT at t=3601 (after exp)", w.checkJwt(jwt, 3601, "orders-api"));
console.log("\n== 2. Audience: a token for orders-api sent to billing-api ==");
const jwtO = w.issueJwt({ sub: "alice", scope: "orders:read", aud: ["orders-api"], now: 0 });
show("aud checked", w.checkJwt(jwtO, 10, "billing-api"));
show("aud NOT checked (the mistake)", w.checkJwt(jwtO, 10, "billing-api", { checkAud: false }));
console.log("\n== 3. API key: a leaked key, used at any time ==");
const key = w.createApiKey("reporting-job");
show("key at t=10", w.checkApiKey(key));
show("same key at t=100000000", w.checkApiKey(key));
w.apiKeys.clear();
show("after the key is deleted", w.checkApiKey(key));
console.log("\n== 4. Authenticated is not authorized: GET /orders/:id ==");
const orders = new Map([[1, { owner: "alice", item: "keyboard" }], [2, { owner: "bob", item: "monitor" }]]);
const authn = (token) => w.checkJwt(token, 10, "orders-api");
const getOrderAuthnOnly = (token, id) => { const a = authn(token); return a.ok ? { status: 200, body: orders.get(id) } : { status: 401 }; };
const getOrder = (token, id) => {
const a = authn(token);
if (!a.ok) return { status: 401 };
if (!a.scope.split(" ").includes("orders:read")) return { status: 403, why: "missing scope" };
const o = orders.get(id);
if (!o) return { status: 404 };
return o.owner === a.sub ? { status: 200, body: o } : { status: 403, why: "not your order" };
};
const tAlice = w.issueJwt({ sub: "alice", scope: "orders:read", aud: ["orders-api"], now: 0 });
const tNoScope = w.issueJwt({ sub: "alice", scope: "profile", aud: ["orders-api"], now: 0 });
const line = (label, r) => console.log(`${label}\n ${JSON.stringify(r)}`);
line("alice reads bob's order, authentication only:", getOrderAuthnOnly(tAlice, 2));
line("alice reads bob's order, ownership checked:", getOrder(tAlice, 2));
line("alice reads her own order, ownership checked:", getOrder(tAlice, 1));
line("token without the orders:read scope:", getOrder(tNoScope, 1));References
- RFC 6749: The OAuth 2.0 Authorization Framework (§1.4, §4.4)
- RFC 6750: The OAuth 2.0 Bearer Token Usage (§1.2, §5.2)
- RFC 7009: OAuth 2.0 Token Revocation
- RFC 7662: OAuth 2.0 Token Introspection (§1, §2.2)
- RFC 8725: JSON Web Token Best Current Practices (§3.9)
- RFC 9068: JWT Profile for OAuth 2.0 Access Tokens (§2.1, §4)
- OpenID Connect Core 1.0 (Abstract)
- OWASP API Security Top 10 2023 (the latest edition when we checked on October 21, 2026; the previous one is 2019); API1, API2
- OWASP Session Management Cheat Sheet
Related tools
- JWT Decoder — Decodes the header and payload locally and does not verify the signature. Use it to inspect a token's audience and expiry, not to decide whether to trust it.
Small Tools is built and operated by ZIV LABS. These links go to tools.ziv-labs.com.
Related notes
- JWT verification mistakes — Four tokens a flawed JWT verifier accepts and a strict one rejects: alg none, RS256-to-HS256 confusion, and unchecked claims, reproduced with runnable Node.js code.
- OAuth grants and leaked tokens — What Vercel's April 2026 bulletin states about an incident that began with a third-party AI tool's OAuth app, and four local tests of the controls that limit a leaked grant: PKCE, exact redirect URI matching, narrow scopes and audiences.