Skip to main content
  1. Posts/

How JWT Tokens Actually Work

JSON Web Tokens are everywhere. Every OAuth flow, every API authentication layer, every modern SSO system uses them. But most developers treat them as opaque blobs — copy a token, paste it into a header, move on. This article breaks open the black box.

After reading this, try decoding and editing JWTs yourself in the JWT Playground.

What Is a JWT?
#

A JWT is a compact, URL-safe string that carries claims (statements about a user or entity) in a way that can be cryptographically verified. It’s defined in RFC 7519.

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IktldmluIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Three parts separated by dots: Header, Payload, and Signature.

┌────────────────┐   ┌────────────────────────┐   ┌──────────────────┐
│    HEADER       │ . │       PAYLOAD          │ . │    SIGNATURE     │
│                │   │                        │   │                  │
│  Algorithm     │   │  Claims about the user │   │  Proof that the  │
│  Token type    │   │  Issuer, expiry, etc.  │   │  header+payload  │
│                │   │                        │   │  haven't been    │
│  Base64URL     │   │  Base64URL encoded     │   │  tampered with   │
│  encoded       │   │                        │   │                  │
└────────────────┘   └────────────────────────┘   └──────────────────┘

Anatomy: Decoding Each Part
#

Header#

The header declares the algorithm and token type:

{
  "alg": "HS256",
  "typ": "JWT"
}

Base64URL-encoded: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Common algorithms:

  • HS256 — HMAC with SHA-256. Symmetric: same secret signs and verifies.
  • RS256 — RSA with SHA-256. Asymmetric: private key signs, public key verifies.
  • ES256 — ECDSA with P-256. Asymmetric, smaller keys than RSA.

Payload
#

The payload contains claims — key-value pairs about the subject:

{
  "sub": "1234567890",
  "name": "Kevin",
  "iat": 1516239022
}

Base64URL-encoded: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IktldmluIiwiaWF0IjoxNTE2MjM5MDIyfQ

Important: the payload is encoded, not encrypted. Anyone who has the token can decode the payload. Never put secrets in the payload.

Signature
#

The signature prevents tampering:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

If anyone modifies a single character in the header or payload, the signature won’t match. The verifier recomputes the signature with the known secret/public key and compares.

Standard Claims
#

The JWT specification defines a set of registered claims — optional but recommended:

ClaimNamePurposeExample
issIssuerWho created the token"https://auth.example.com"
subSubjectWho the token is about"user:42" or "kevin@example.com"
audAudienceWho should accept the token"https://api.example.com"
expExpirationWhen the token expires (Unix timestamp)1711929600
iatIssued AtWhen the token was created1711926000
nbfNot BeforeToken is not valid before this time1711926000
jtiJWT IDUnique identifier for this specific token"abc-123-def"

exp is the most important one. Without it, a stolen token works forever.

The Token Lifecycle
#

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   Client      │     │  Auth Server │     │  API Server  │
│   (Browser)   │     │  (IdP)       │     │  (Resource)  │
└──────┬───────┘     └──────┬───────┘     └──────┬───────┘
       │                     │                     │
       │  1. Login           │                     │
       │  (credentials)      │                     │
       │────────────────────▶│                     │
       │                     │                     │
       │  2. JWT returned    │                     │
       │  (access_token)     │                     │
       │◀────────────────────│                     │
       │                     │                     │
       │  3. API request     │                     │
       │  Authorization:     │                     │
       │  Bearer <JWT>       │                     │
       │─────────────────────┼────────────────────▶│
       │                     │                     │
       │                     │     4. Verify JWT   │
       │                     │     - Check sig     │
       │                     │     - Check exp     │
       │                     │     - Check aud     │
       │                     │                     │
       │  5. Response         │                     │
       │◀─────────────────────┼────────────────────│
       │                     │                     │

Key insight: the API server never talks to the auth server during steps 3-5. The JWT is self-contained — the API server has the public key (or shared secret) and can verify the token independently. This is why JWTs scale so well: no session store, no auth server round-trip.

JWTs in OAuth 2.0 / OIDC
#

In OAuth/OIDC flows, you encounter three types of tokens:

Access Token
#

The most common JWT. Used to authorize API requests. Short-lived (minutes to hours).

{
  "iss": "https://auth.example.com",
  "sub": "user:42",
  "aud": "https://api.example.com",
  "scope": "read:data write:data",
  "exp": 1711929600,
  "iat": 1711926000
}

ID Token
#

An OIDC-specific token that identifies the user. Contains profile information. Used by the client application, never sent to APIs.

{
  "iss": "https://auth.example.com",
  "sub": "user:42",
  "aud": "my-app-client-id",
  "name": "Kevin Keller",
  "email": "kevin@example.com",
  "email_verified": true,
  "exp": 1711929600
}

Refresh Token
#

Not a JWT in most implementations — it’s an opaque string stored server-side. Used to get new access tokens without re-authentication. Long-lived (days to weeks). Never send it to an API — only to the auth server’s token endpoint.

How Signing Actually Works
#

Symmetric (HS256)
#

Both parties share the same secret:

Sign:    HMAC-SHA256(header.payload, "my-secret-key") → signature
Verify:  HMAC-SHA256(header.payload, "my-secret-key") == signature?

Simple, fast. But both the issuer and verifier need the same secret — which means the secret must be distributed securely. Fine for single-service architectures, problematic for microservices.

Asymmetric (RS256 / ES256)
#

The issuer has a private key, verifiers have the public key:

Sign:    RSA-Sign(header.payload, private_key) → signature
Verify:  RSA-Verify(header.payload, signature, public_key) → true/false

The public key can be published openly (often via a JWKS endpoint at /.well-known/jwks.json). Any service can verify tokens without having access to the signing key. This is why RS256/ES256 is the standard for production OAuth systems.

JWKS — JSON Web Key Set
#

Identity providers publish their public keys at a well-known URL:

GET https://auth.example.com/.well-known/jwks.json
{
  "keys": [
    {
      "kty": "RSA",
      "kid": "key-1",
      "use": "sig",
      "n": "0vx7agoebGc...",
      "e": "AQAB"
    }
  ]
}

The kid (Key ID) in the JWT header tells the verifier which key to use. This allows key rotation without downtime — publish a new key, start signing with it, old tokens still verify with the old key until they expire.

Security Pitfalls
#

1. The alg: none Attack
#

If a server blindly trusts the alg field in the header, an attacker can set it to "none" (no signature), remove the signature, and the server accepts the token as valid.

Fix: Never accept alg: none. Hardcode the expected algorithm on the server side.

2. Weak HMAC Secrets
#

If you use HS256 with a short or guessable secret ("password", "secret", "jwt-secret"), an attacker can brute-force the secret from a captured token.

Fix: Use secrets with at least 256 bits of entropy (32+ random bytes). Better yet, use RS256/ES256.

3. No Expiration
#

A token without exp is valid forever. If leaked, the attacker has permanent access.

Fix: Always set exp. Access tokens: 15-60 minutes. Refresh tokens: hours to days.

4. Token Storage
#

StorageXSS Vulnerable?CSRF Vulnerable?Recommendation
localStorageYesNoAvoid for sensitive tokens
sessionStorageYesNoSlightly better (cleared on tab close)
httpOnly cookieNoYes (mitigated with SameSite)Preferred
Memory (JS variable)PartiallyNoBest for short-lived tokens

The safest pattern: store the access token in memory (JS variable) and the refresh token in an httpOnly, Secure, SameSite=Strict cookie.

5. Accepting Tokens for the Wrong Audience
#

If your API accepts any valid JWT without checking the aud claim, a token issued for Service A can be used to access Service B.

Fix: Always validate aud matches your service identifier.

6. Not Validating the Issuer
#

Similarly, always check iss. A token signed by a different auth server (even with a valid signature) should be rejected.

JWT vs Session Tokens
#

JWTSession Token
StateStateless (self-contained)Stateful (server stores session)
RevocationHard (must wait for expiry or use blocklist)Easy (delete session from store)
ScalabilityExcellent (no shared session store)Requires shared store (Redis, DB)
SizeLarger (~800 bytes+)Small (~32 bytes, just an ID)
InspectionPayload is readableOpaque to the client

JWTs trade revocability for scalability. If you need instant revocation (e.g., “ban this user right now”), you need either short-lived tokens + frequent refresh, or a token blocklist — which partially defeats the stateless benefit.

Try It Yourself
#

Decode, edit, and verify JWTs right in your browser:

JWT Playground — paste any JWT, see the decoded header and payload, edit claims, and verify signatures. Everything runs locally.

Kevin Keller
Author
Kevin Keller
Personal blog about AI, Observability & Data Sovereignty. Snowflake-related articles explore the art of the possible and are not official Snowflake solutions or endorsed by Snowflake unless explicitly stated. Opinions are my own. Content is meant as educational inspiration, not production guidance.
Share this article

Related