Authentication for APIs

API keys, JWT bearer tokens and signature verification, and the OAuth2 authorization code flow.

API keys

The simplest form of API authentication: the client includes a static, secret string with every request, usually as a header:

Http
GET /weather?city=London
X-API-Key: sk_live_51H8f...

API keys are common for server-to-server integrations (a weather API, a payments API) where the "client" is another backend service, not an end user's browser. They're simple to implement and simple to revoke (delete the key server-side), but they're a blunt instrument: a key typically represents an entire account or application, not an individual user, and it must never be exposed in front-end JavaScript, a mobile app binary, or a public repository — anyone who obtains it can use it exactly as the legitimate owner could.

JWT bearer tokens

A JWT (JSON Web Token) is a compact, self-contained token format used to represent an authenticated identity, commonly sent as a bearer token:

Http
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJBbGkiLCJleHAiOjE3NTMxMjM0NTZ9.dBjftJeZ4CVP-mB92K...

A JWT has exactly three parts, separated by dots — header.payload.signature:

Plaintext
eyJhbGciOiJIUzI1NiJ9   .   eyJzdWIiOiI0MiJ9   .   dBjftJeZ4CVP-mB92K...
     header                    payload                signature

Header — identifies the token type and the signing algorithm used (e.g. {"alg": "HS256", "typ": "JWT"}).

Payload — a set of claims: statements about the authenticated entity and the token itself. Common claims:

JSON
{
  "sub": "42",
  "name": "Ali Raza",
  "role": "admin",
  "iat": 1753120000,
  "exp": 1753123600
}
  • sub (subject) — typically the user ID.
  • iat (issued at) / exp (expiration) — timestamps controlling the token's validity window.
  • Custom claims (role, name, etc.) — whatever the issuer wants to embed.

Signature — a cryptographic signature over the header and payload, computed with a secret (HMAC algorithms like HS256) or a private key (asymmetric algorithms like RS256). This is the part that makes a JWT trustworthy.

Why you verify the signature, not just decode the payload

This is the single most important thing to understand about JWTs: the header and payload are only base64url-encoded, not encrypted. Anyone can decode them and read the claims — paste any JWT into a decoder and you'll see the plain JSON. This means a JWT should never contain secrets (passwords, credit card numbers) in its payload, since anyone holding the token can read it.

The security guarantee comes entirely from the signature. On every request, the server must:

  1. Decode the token to read the claims.
  2. Re-compute the signature using its own secret/public key and compare it against the signature in the token.
  3. Only trust the claims if the signatures match.
Javascript
import jwt from 'jsonwebtoken';

function verifyToken(token) {
  try {
    // jwt.verify checks the signature AND expiration — this is the
    // step that actually matters for security
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    return payload; // { sub: '42', role: 'admin', iat: ..., exp: ... }
  } catch (error) {
    throw new Error('Invalid or expired token');
  }
}

A server that merely decodes a token (reads the payload as base64 JSON) without verifying the signature is trusting the claims blindly — an attacker could hand-craft a token claiming "role": "admin" and the server would believe it, because nothing checked whether that payload was actually signed by a trusted issuer. This is a real, recurring vulnerability class: always use a library's verify() function (which checks the signature and expiry), never a bare decode() function, to authenticate a request.

OAuth2, conceptually

OAuth2 is a framework for delegated authorization — letting a user grant a third-party application limited access to their data on another service, without ever sharing their password with that third-party app. "Log in with Google" is the pattern most people recognize.

The most common flow, the authorization code flow, works like this:

Plaintext
1. User clicks "Log in with Google" on YourApp
2. YourApp redirects the user's browser to Google's authorization server
3. User logs into Google (if not already) and approves YourApp's requested access
4. Google redirects back to YourApp with a short-lived, one-time "authorization code"
5. YourApp's backend exchanges that code (plus its own client secret) for an access token
   -- this exchange happens server-to-server, so the token never touches the browser directly
6. YourApp uses the access token to call Google's APIs on the user's behalf
Plaintext
Browser                YourApp Backend              Google Auth Server
   |--- click login -------->|                              |
   |<---- redirect to Google ------------------------------- |
   |----------- login + approve ---------------------------->|
   |<---- redirect back with ?code=abc123 --------------------|
   |--- code -------------->|                              |
   |                        |--- code + client secret ----->|
   |                        |<---- access token -------------|
   |<-- logged in ----------|                              |

The key insight: the authorization code passed through the browser is short-lived and useless on its own — exchanging it for the actual access token requires the app's confidential client secret, which only the backend holds. This is what prevents a stolen redirect URL from being directly usable as a working credential.

Comparing the three approaches

API keys JWT bearer tokens OAuth2
Typical use Server-to-server, simple integrations Stateless session auth for your own API Delegated access to a third-party's API on a user's behalf
Represents An application/account An authenticated user session A user's consent, scoped to specific permissions
Revocation Delete/rotate the key server-side Hard to revoke before expiry (unless you maintain a blocklist) Revoke the granted access via the authorization server
Complexity Low Medium Higher — requires a full authorization server

Common mistakes

  • Storing sensitive data (passwords, full card numbers) inside a JWT payload, forgetting that the payload is only encoded, not encrypted, and readable by anyone holding the token.
  • Authenticating a request by decoding a JWT's payload without calling a verify() function that checks the signature — this accepts any hand-crafted, unsigned token as genuine.
  • Using an overly long exp (expiration) on access tokens, making a leaked token dangerous for a long window — short-lived access tokens paired with a separate refresh token are the standard mitigation.

Interview questions

Q: Is the payload of a JWT encrypted? No — it's only base64url-encoded, which means anyone can decode and read it without any secret. The security comes entirely from the signature, which lets a server detect if the payload has been tampered with. Sensitive secrets should never be placed in a JWT payload.

Q: Why is it dangerous to authenticate a request by just decoding a JWT instead of verifying it? Decoding only reads the claims as-is, with no check that they were actually issued by a trusted party — an attacker can hand-craft a token with any claims they want (like "role": "admin") and a server that only decodes, never verifies the signature, will accept it as genuine. Verification recomputes the expected signature and compares it, which only succeeds if the token was signed with the correct secret or private key.

Q: In the OAuth2 authorization code flow, why is the authorization code exchanged for a token on the backend instead of directly in the browser? Because the exchange requires the application's client secret, which must never be exposed to the browser (anyone viewing page source or network traffic could steal it). Keeping that exchange server-to-server means a stolen authorization code alone isn't enough to obtain a usable access token — the attacker would also need the confidential client secret, which only the backend holds.