A signed, self-contained token that carries a user's identity from your auth provider to your API — without the server having to look anything up.
Research-based overview. This article synthesizes RFC 7519 (the JWT specification), public documentation from Auth0, Supabase, and Clerk, and OWASP guidance on token storage. How we research.
Pronounced “jot” by the people who wrote the spec and “jay-double-you-tee” by everyone else, a JWT is the token format that quietly powers most modern SaaS authentication. When a user signs in to a Next.js app backed by Supabase, Clerk, or Auth.js, the artifact that gets handed back to the browser and replayed on every API call is almost always a JWT. Understanding what is inside one — and what should never be inside one — is the difference between auth that holds up and auth that quietly leaks.
A JWT looks like a long, mildly garbled string with two dots in it. Decoded, it is three pieces:
Each segment is base64url-encoded (the URL-safe variant of base64, which swaps +/ for -_ and drops padding). When you base64url-decode the first two segments, you get JSON:
The header tells the verifier which algorithm was used to sign the token. The payload contains the claims — standardised fields like sub (subject, almost always the user ID), iat (issued at), exp (expires at), iss (issuer), and aud (audience), plus any custom claims the issuer wants to ship along. The signature is computed over base64url(header) + "." + base64url(payload) using the algorithm in the header and a secret or private key only the issuer holds.
Two important consequences fall out of that structure. First, the payload is not encrypted, only encoded. Anyone holding the token can read the claims by base64url-decoding the middle segment — tools like jwt.io will do it for you. Second, the signature is what makes the token trustworthy. If an attacker tampers with even one byte of the payload, the signature stops verifying, and any well-behaved server rejects the token.
The umbrella term “JWT” covers two cousins from the JOSE (JavaScript Object Signing and Encryption) family of specs: JWS (JSON Web Signature, RFC 7515) and JWE (JSON Web Encryption, RFC 7516). The signed-only variant — JWS — is overwhelmingly the one you encounter in SaaS auth. The payload is readable by anyone, but the signature guarantees integrity.
Encrypted JWTs (JWEs) wrap an encrypted payload inside a different five-segment structure. They are useful when claims contain genuinely sensitive data, but they are rare in practice for one reason: well-designed JWTs do not contain sensitive data. They contain a user ID and a few flags. If you find yourself reaching for JWE, you have probably stuffed something into the payload that should have stayed in the database.
The header's alg field identifies how the signature was made. Three families dominate:
Auth0's and Clerk's hosted issuers default to RS256. Supabase Auth historically used HS256 with a project-wide secret and has been migrating projects toward asymmetric signing. Whichever family you use, the rule is the same: never accept tokens whose alg is none. The infamous “alg: none” vulnerability from 2015 is still appearing in audit reports a decade later because some libraries trust the header instead of pinning the algorithm in the verifier.
The whole point of a signed, self-contained token is that the server does not need to store session state. With a session-cookie design, every authenticated request triggers a database lookup — the cookie holds an opaque session ID, and the server reads the row to figure out which user this is. With a JWT, the user ID is already in the token. The server verifies the signature once (using a key it already has cached), reads sub out of the payload, and proceeds.
For solo SaaS founders, that buys three things:
Real-world JWT setups almost always issue two tokens at sign-in:
The split exists because long-lived JWTs are dangerous — once issued, you cannot easily revoke them — and short-lived JWTs without a refresh would log users out every fifteen minutes. The refresh token is typically opaque (not a JWT), stored in a secure HTTP-only cookie, and tied to a server-side record so it can be revoked. Auth0's, Clerk's, and Supabase Auth's docs all describe variations of this pattern.
localStorage. XSS vulnerabilities become full account takeover, because any script on the page can read the token and exfiltrate it. OWASP's guidance is to keep tokens in HTTP-only, Secure, SameSite cookies whenever possible. SPAs that can't use cookies for cross-origin reasons should at least keep the access token in memory and rely on a cookie-based refresh flow.iss and aud against your own values.exp claim is a credential that lives forever. Issue tokens with short expiries, reject expired ones, and rotate refresh tokens.alg field from the token. Hard-code the expected algorithm in your verifier. Otherwise an attacker can switch RS256 for HS256 and trick a buggy library into using the public key as the HMAC secret.jti) or use very short expiries.You will read a lot of takes online claiming JWTs are an anti-pattern and you should use session cookies instead. The pragmatic answer is more boring than either side admits: they are not actually opposites. Most modern auth providers issue a JWT inside an HTTP-only cookie, getting the integrity properties of a signed token and the storage properties of a cookie at the same time.
| Property | JWT (in localStorage) | JWT (in cookie) | Opaque session cookie |
|---|---|---|---|
| Stateless verification | Yes | Yes | No (DB lookup per request) |
| XSS exposure | High | Low (HTTP-only) | Low (HTTP-only) |
| CSRF exposure | None (header-based) | Yes (needs SameSite/CSRF token) | Yes (needs SameSite/CSRF token) |
| Easy revocation | Hard | Hard | Easy (delete server row) |
| Cross-domain APIs | Easy | Harder (CORS + cookie config) | Harder |
The practical guidance: use whichever format your auth provider issues by default, store it in an HTTP-only cookie if same-origin, and don't over-engineer. The interesting security questions are about token lifetime and refresh handling, not about the token format.
You almost certainly should not write your own JWT issuer. The hosted and open-source options that handle this for solo founders are mature and cheap or free at small scale:
The JWT’s killer use case in the Supabase stack is that Postgres itself reads the token. When a request hits PostgREST with an Authorization: Bearer <jwt> header, the gateway verifies the signature, then sets a Postgres session variable containing the decoded claims. RLS policies on tables can then reference auth.uid() — a SQL function that pulls sub out of the JWT — to filter rows. The result: a single signed token, issued at sign-in, drives every authorisation decision from the API edge down to the row level. We walk through the wiring in what is RLS and the multi-tenant patterns in what is multi-tenancy. JWTs also show up alongside other integration primitives like webhooks, where they often sign the webhook payload to prove the sender.
A JWT is three base64url-encoded segments with a signature on the end. It carries a user’s identity in a way any service holding the verification key can trust without a database lookup. For solo SaaS, that property — stateless, cross-service identity — is what makes the token format worth using. The mistakes are well-known and avoidable: keep tokens out of localStorage, pin algorithms in your verifier, give every token an expiry, and never put PII in the payload. Get those four right and the format does its job quietly for the entire life of your product.
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.