What API authentication actually decides: credential, transport and validation

API authentication answers one question per request: who is calling? Any scheme that answers it makes three independent decisions, and most "jwt vs sessions" arguments go wrong by treating them as a single choice.

Credential type. An opaque credential, such as a random session ID or an API key, carries no meaning. It is a pointer to state the server holds. A self-contained token, such as a JWT, carries its own claims (subject, expiry, scopes) plus a signature that proves the server issued it.

Transport. A credential travels either in a cookie, which the browser attaches automatically, or in the Authorization header, which the client must attach explicitly. Cookies can be marked HttpOnly so page scripts cannot read them, but automatic sending makes them vulnerable to cross-site request forgery. Headers avoid that problem, but the client has to store the token somewhere it can reach.

Validation. The server either looks the credential up in a database or cache, or verifies a signature locally. A lookup costs a round trip, and in exchange revocation takes effect immediately. A signature check costs only CPU, and in exchange a token stays valid until it expires unless you add extra state.

Concern Options What the choice controls
Credential Opaque reference / self-contained token Where identity data lives
Transport Cookie / Authorization header CSRF exposure, client storage
Validation Server-side lookup / signature check Per-request cost, revocation speed

These axes combine freely. You can put a JWT in a cookie or send an opaque session ID in a header. This article treats sessions, JWTs and API keys as three common combinations of these axes. Each one answers a different question about who the caller is, how the credential is checked and how quickly access can be withdrawn.

                 +---------------------------------------+
                 |  API request: "Who is calling?"      |
                 +-------------------+-------------------+
                                     |
          +--------------------------+--------------------------+
          |                          |                          |
          v                          v                          v
+--------------------+     +--------------------+     +--------------------+
| 1. CREDENTIAL      |     | 2. TRANSPORT       |     | 3. VALIDATION      |
+--------------------+     +--------------------+     +--------------------+
| opaque reference   |     | cookie             |     | server-side lookup |
|  session ID/API key|     |  sent by browser,  |     |  DB/cache per call,|
|                    |     |  CSRF exposure     |     |  revoke instantly  |
| self-contained     |     | Authorization hdr  |     | signature check    |
|  token (JWT) with  |     |  attached by client|     |  CPU only; valid   |
|  claims + signature|     |  must store token  |     |  until expiry      |
+--------------------+     +--------------------+     +--------------------+

 Typical combinations (the axes combine freely):
 +-----------+------------------+---------------+--------------------+
 | Pattern   | Credential       | Transport     | Validation         |
 +-----------+------------------+---------------+--------------------+
 | Session   | opaque ID        | cookie        | lookup             |
 | JWT       | self-contained   | header        | signature          |
 | API key   | opaque key       | header        | lookup             |
 +-----------+------------------+---------------+--------------------+

Every API authentication scheme makes three independent choices: credential type, transport and validation. Sessions, JWTs and API keys are common combinations of those choices, not fixed bundles.

How server-side sessions work and how to implement them safely

A server-side session issues the client an opaque, random identifier with no meaning of its own. The server keeps the actual state (user ID, roles, creation time, last activity) in a shared store keyed by that ID. Validation is a lookup: if the row exists and hasn't expired, the caller is authenticated. Revocation is equally direct. Delete the row, and the next request fails. No token stays valid in the wild.

In browsers the ID usually travels in a cookie such as Set-Cookie: sid=9f2c…; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600. HttpOnly keeps the value out of JavaScript, which limits what an XSS bug can steal. Secure restricts it to HTTPS. SameSite controls whether the browser attaches the cookie to cross-site requests. Because browsers attach cookies automatically, another site can trigger authenticated requests on the user's behalf. Treat SameSite as one layer and pair it with CSRF tokens or origin checks for state-changing endpoints.

Rotate the ID whenever the authentication level changes, at minimum on login and on privilege elevation. On a successful login, do the following:

  1. Create a new random ID.
  2. Copy over any pre-login state you need.
  3. Delete the old row.
  4. Set the new cookie.

This defeats session fixation, where an attacker plants a known ID before the victim logs in.

The cost is operational. Every request depends on the store, so it must be highly available and replicated. The lookup itself, a single primary-key read, is rarely the bottleneck compared with the database queries and downstream calls the request makes anyway.

import { randomBytes } from "node:crypto";
import type { Request, Response } from "express";

// Minimal interface over a shared, replicated store (e.g. a key-value cluster).
interface SessionStore {
  get(id: string): Promise<Record<string, unknown> | null>;
  set(id: string, data: Record<string, unknown>, ttlSeconds: number): Promise<void>;
  delete(id: string): Promise<void>;
}

const SESSION_TTL_SECONDS = 3600;

// 32 random bytes as hex: opaque, unguessable, carries no meaning itself.
const newSessionId = () => randomBytes(32).toString("hex");

// Call after the password (or other factor) has been verified.
export async function rotateSessionOnLogin(
  req: Request, res: Response, store: SessionStore, userId: string
) {
  const oldId: string | undefined = req.cookies?.sid; // requires cookie-parser
  const oldData = oldId ? await store.get(oldId) : null;

  // 1. New random ID, so a pre-planted (fixated) ID is never authenticated.
  const newId = newSessionId();

  // 2. Carry over only the pre-login state you actually need.
  const data = { cartId: oldData?.cartId, userId, createdAt: Date.now() };
  await store.set(newId, data, SESSION_TTL_SECONDS);

  // 3. Delete the old row; any copy of the old ID is now useless.
  if (oldId) await store.delete(oldId);

  // 4. Set the new cookie: not readable by JS, HTTPS only, limited cross-site sending.
  res.cookie("sid", newId, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
    maxAge: SESSION_TTL_SECONDS * 1000, // Express takes milliseconds
  });
}

An Express login handler helper that rotates the session on successful login. It creates a new random ID, copies the needed pre-login state, deletes the old row from the store and sets an HttpOnly, Secure, SameSite=Lax cookie.

How JWTs work: signatures, claims and validation rules

A JWT removes that lookup. The token carries its own claims, such as subject, issuer, audience and expiry, plus a signature over them. Any service holding the verification key can check the token locally.

The payload is base64url-encoded, not encrypted. Anyone holding the token can read it, so it must never contain secrets or sensitive personal data.

Verifying a token

Treat the token header as untrusted input. If the server accepts whatever alg the header names, an attacker can choose none or swap an asymmetric algorithm for HMAC. A safe verifier does the following, in order:

  1. Pin the accepted algorithms to an explicit allowlist, for example only RS256, and select the key from server configuration.
  2. Verify the signature before reading any claim.
  3. Reject the token if exp has passed or nbf is still in the future, allowing a small clock skew.
  4. Require iss to equal your issuer and aud to include this service.
Choice HMAC (shared secret) Asymmetric (key pair)
Who can verify Holders of the secret Anyone with the public key
Who can mint Every verifier Only the private-key holder
Fit A single service Many independent consumers

Trade-offs

  • Token size: Tokens are sent on every request and are much larger than a session ID.
  • Stale claims: If a user's role is revoked, the old role stays valid in issued tokens until they expire.
  • Storage: JWTs do not change the browser storage problem. A token in localStorage can be stolen by XSS. A token in a cookie brings back CSRF, so you still need the same defences as with session cookies.
# Pseudocode: verify an incoming JWT. The header is untrusted input.
ALLOWED_ALGS  = ["RS256"]                     # explicit allowlist; never "none"
VERIFY_KEY    = load_public_key("/etc/auth/issuer-public.pem")  # from config, not from the token
EXPECTED_ISS  = ""
THIS_AUDIENCE = "orders-api"
CLOCK_SKEW    = 30 seconds

function verify_jwt(token):
    header_b64, payload_b64, sig_b64 = split(token, ".")   # reject unless exactly 3 parts
    header = json_decode(base64url_decode(header_b64))

    # 1. Pin the algorithm. The header may only name one we already allow.
    #    The key comes from server config, so swapping RS256 for HS256 fails.
    if header.alg not in ALLOWED_ALGS:
        reject("algorithm not allowed")

    # 2. Check the signature before trusting any claim.
    signing_input = header_b64 + "." + payload_b64
    if not rsa_sha256_verify(VERIFY_KEY, signing_input, base64url_decode(sig_b64)):
        reject("bad signature")

    # The payload is only encoded, not encrypted: anyone holding the token can read it.
    claims = json_decode(base64url_decode(payload_b64))
    now = current_unix_time()

    # 3. Time window, with a small allowance for clock skew. exp is required.
    if claims.exp is missing or now > claims.exp + CLOCK_SKEW:
        reject("expired")
    if claims.nbf is present and now < claims.nbf - CLOCK_SKEW:
        reject("not yet valid")

    # 4. Issuer and audience. aud may be a string or a list.
    if claims.iss != EXPECTED_ISS:
        reject("wrong issuer")
    if THIS_AUDIENCE not in as_list(claims.aud):
        reject("wrong audience")

    # Claims such as role are as fresh as the token's issue time, not the current state.
    return claims

Language-neutral pseudocode for a JWT verifier. It pins the accepted algorithm and key in server configuration, checks the signature before reading any claim, and then validates exp, nbf, iss and aud.

Token expiry and revocation: refresh tokens, rotation and the statelessness myth

A signed access token stays valid until its exp claim passes, whatever happens on the server. The usual compromise is a short-lived access token paired with a longer-lived refresh token. The refresh token is only ever sent to the token endpoint, which checks it against stored state before issuing a new access token.

Rotation with reuse detection makes refresh tokens single-use. Each refresh returns a new refresh token and invalidates the old one. If an old token is presented again, either the client or an attacker holds a copy, so the server revokes the whole token family and forces re-authentication.

This means storing token families, used-token markers and, for immediate revocation, a denylist of token IDs or a per-user token version checked on every request. All of that is server-side state. The system is not stateless any more. It simply keeps less state and reads it less often.

Illustrative estimate (a calculation, not a measurement): assume an access token is stolen at issue and the compromise is detected 5 minutes later, with no denylist.

Access token lifetime Remaining usable window
5 minutes ~0 minutes
15 minutes up to ~10 minutes
60 minutes up to ~55 minutes

Shorter lifetimes cost more refresh traffic and add failure paths:

  • Clock skew: tokens can be rejected early or accepted late, so allow a small leeway.
  • Concurrent refreshes: two parallel requests can trip reuse detection unless you allow a brief grace period.
  • Offline mobile clients: a client that has been offline can return holding an expired refresh token, which forces the user to sign in again.
import hashlib, secrets, time

REFRESH_TTL = 30 * 24 * 3600  # long-lived refresh token (assumed 30 days)
GRACE_SECONDS = 10            # tolerate concurrent refreshes from one client
store = {}  # token hash -> record. This is server-side state; use a database.

def _h(token):
    # Store only hashes so a leaked table cannot be replayed.
    return hashlib.sha256(token.encode()).hexdigest()

def issue(user_id, family=None):
    token = secrets.token_urlsafe(32)
    store[_h(token)] = {"user": user_id, "family": family or secrets.token_hex(8),
                        "used_at": None, "expires": time.time() + REFRESH_TTL}
    return token

def revoke_family(family):
    for rec in store.values():
        if rec["family"] == family:
            rec["expires"] = 0

def refresh(token):
    now = time.time()
    rec = store.get(_h(token))
    if rec is None or rec["expires"] <= now:
        raise PermissionError("invalid or expired: sign in again")
    if rec["used_at"] is not None:
        if now - rec["used_at"] <= GRACE_SECONDS:
            # Likely a parallel request racing the first refresh: retry, don't revoke.
            raise RuntimeError("already rotated: retry with the newest token")
        # Old token replayed after the grace period: client or attacker has a copy.
        revoke_family(rec["family"])
        raise PermissionError("reuse detected: family revoked, sign in again")
    rec["used_at"] = now  # single-use: mark before issuing the successor
    # A new short-lived access token (e.g. 15 min) would be minted here too.
    return issue(rec["user"], rec["family"])

A minimal in-memory sketch of refresh token rotation with reuse detection, a short grace period for concurrent refreshes, and family-wide revocation, all of which depend on stored server-side state.

API keys: identifying applications, not users

An API key answers a different question than a session or a JWT. It identifies a calling application or account, not the person at the keyboard. Keys are long-lived by design, often valid until someone revokes them, so how they are created and stored matters more than for short-lived tokens.

Generate each key from a cryptographically secure random number generator with at least 128 bits of entropy. Because the key is random rather than a password chosen by a person, a fast hash such as SHA-256 is sufficient for storage; slow password hashes add latency to every request without adding meaningful protection. Store only the hash, show the full key exactly once at creation, and look up records by a short non-secret identifier embedded in the key. Compare hashes in constant time so response timing does not leak how many bytes matched.

Give keys a recognizable prefix, such as acme_live_. A fixed pattern lets log scrubbers, repository scanners and your own alerting detect a leaked key before an attacker uses it. Encode the environment in the prefix, and attach permission scopes to each key so a read-only reporting job cannot write.

Allow several active keys per account. Customers can then create a new key, deploy it, and revoke the old one with no downtime.

The weaknesses are operational. Keys tend to be over-privileged because "full access" is the easiest choice, and they get copied into CI configs and shared scripts. Rotation depends on customers, so many keys will never be rotated. Plan for that: record last-used timestamps, flag dormant keys, and make revocation fast.

Which pattern to use, when to avoid each, and the alternatives

The right pattern depends less on preference than on who holds the credential and where it lives.

Client type Default pattern Why Avoid
First-party browser app Session cookie (HttpOnly, Secure, SameSite) The browser stores and sends it automatically, and JavaScript cannot read it JWTs in localStorage, where any XSS can exfiltrate them
Mobile and native apps Short-lived access token plus refresh token in platform secure storage No cookie jar to rely on, and the OS keystore protects the long-lived secret Long-lived access tokens with no refresh path
Service-to-service Short-lived signed tokens or mutual TLS Credentials expire or are bound to a certificate, so leaks age out Shared static keys copied between services
Third-party developers API keys, or OAuth when acting for a user Keys identify the integrating application, and OAuth adds user consent and scopes Asking users for their passwords

The failure modes are just as specific. Avoid JWTs when you need instant revocation, such as banking logouts or compromised-account lockouts. Once you add a denylist check on every request, you are running sessions with extra steps. Avoid API keys as end-user identity: a key says which integration called, not which person, and it is usually shared across a team. Avoid sessions for non-browser machine clients, because cookies, CSRF defences and sliding expiry add complexity there without adding security.

Two alternatives deserve more use than they get. Mutual TLS moves authentication into the transport layer, which suits internal meshes where you already run a certificate authority. OAuth is the answer whenever a third party needs delegated, scoped and revocable access to a user's data.

Operational hygiene and impact on cost and engineering time

Practices every pattern shares

Some hygiene applies to sessions, JWTs and API keys alike. Log a credential's identifier or a short prefix, never the full value. Logs get copied into more systems than secrets do. Rate limit authentication endpoints and lock out repeated failures per account and per source, because these endpoints are where guessing and credential stuffing happen. Compare secrets with a constant-time function so response timing doesn't reveal how many leading bytes matched. Put a key ID in every signed token and keep more than one verification key active at a time. That lets you rotate signing keys on a schedule, without an outage. Finally, write the leaked-credential runbook before you need it: who revokes, how to find every affected credential, and how to tell clients.

Where the cost actually lands

JWTs don't remove complexity. They move it. Rotation, multi-key verification and key distribution become your code to maintain, and the failures hit everyone at once. If a service signs with a key ID that verifiers don't know, or a verifier holds a stale key cache after rotation, every token fails validation together. Sessions trade that risk for a dependency on the session store, which you have to run, scale and monitor.

The bigger saving comes from how you choose. Choose by client type and by how quickly a revocation must take effect, not by whether a design is "stateless." Teams that pick JWTs for statelessness often add a denylist later, once they need fast logout. They end up with state anyway, plus the token machinery, and sometimes a second migration back to sessions. Settling the revocation requirement first avoids building and then undoing that work.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to api authentication, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.