A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe string that carries a set of claims, such as who a user is and when their login expires, together with a signature that lets the receiver check the claims were not altered. A JWT looks like eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c: three Base64url-encoded segments separated by dots. Decode the middle segment and you get {"sub":"1234567890","name":"John Doe","iat":1516239022}. JWTs are defined in RFC 7519 and are the standard way to pass identity between a login server, an API and a client.
What is a JWT used for?
- Authentication. After a user signs in, the server issues a JWT. The client sends it with each request, usually in an
Authorization: Bearer <token>header, and the API trusts the claims without looking anything up. - Authorization. Claims such as roles or scopes tell the API what the caller may do.
- Federated identity. OpenID Connect ID tokens are JWTs, which is how "Sign in with Google" hands your identity to another site.
- Short-lived, self-contained messages between services, such as signed webhook payloads or password-reset links.
The key property is that a JWT is self-contained and verifiable: any server that holds the right key can check it without a database call.
The three parts of a JWT
Splitting the example token at the dots gives a header, a payload and a signature.
1. Header
{
"alg": "HS256",
"typ": "JWT"
}
alg names the signing algorithm and typ says this is a JWT. Tokens signed with an asymmetric key often add kid, a key ID that tells the verifier which public key to use. Base64url-encoded, this header is eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
2. Payload
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
The payload holds the claims. sub identifies the subject (the user), name is a custom claim, and iat is the Unix timestamp the token was issued, in this case 18 January 2018 at 01:30:22 UTC. Encoded, it becomes eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
3. Signature
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The signature is computed over the first two segments using the algorithm from the header and a key. For this token the key is the string your-256-bit-secret, which is the default on jwt.io and the reason this particular token appears in so many tutorials.
| Part | Contains | Who can read it | Who can create it |
|---|---|---|---|
| Header | Algorithm and token type | Anyone | Anyone |
| Payload | Claims | Anyone | Anyone |
| Signature | HMAC or digital signature over header + payload | Anyone | Only the key holder |
Base64url, not Base64
The segments use Base64url (RFC 4648 section 5) rather than standard Base64: + becomes -, / becomes _, and the = padding is dropped. That is what makes a JWT safe to put in a URL, a cookie or an HTTP header without escaping. It also means the header and payload are merely encoded, not hidden. Anyone can decode them:
const payload = JSON.parse(atob(token.split('.')[1]));
Our JWT Decoder does exactly this in your browser. For the mechanics of the encoding, see what is Base64 encoding. Never put passwords, card numbers or other secrets in a JWT payload.
Standard claims
RFC 7519 registers seven claim names. All are optional, but exp and sub appear in almost every token.
| Claim | Name | Type | Meaning |
|---|---|---|---|
iss |
Issuer | String or URL | Who created and signed the token, e.g. https://auth.example.com |
sub |
Subject | String | Who the token is about, usually a user ID |
aud |
Audience | String or array | Who the token is intended for, e.g. https://api.example.com |
exp |
Expiration time | Unix timestamp | The token must not be accepted after this time |
nbf |
Not before | Unix timestamp | The token must not be accepted before this time |
iat |
Issued at | Unix timestamp | When the token was created |
jti |
JWT ID | String | A unique identifier, used to detect replay or to revoke individual tokens |
Beyond these, public claims are names registered with IANA or made collision-resistant with a URL ("https://example.com/roles"), and private claims are whatever the two parties agree on ("department": "engineering"). OpenID Connect adds its own set for ID tokens, including email, name, nonce, auth_time and at_hash.
A realistic access token payload:
{
"iss": "https://auth.example.com",
"sub": "user_12345",
"aud": "https://api.example.com",
"exp": 1705312800,
"iat": 1705309200,
"jti": "9f8c2b6e-1c3d-4b7a-9e2f-0a1b2c3d4e5f",
"scope": "read:profile write:profile",
"roles": ["user", "premium"]
}
How the signature is computed
For HS256, the signature is an HMAC-SHA256 over the two encoded segments joined by a dot, with the result itself Base64url-encoded:
signature = base64url( HMAC-SHA256( secret, base64url(header) + "." + base64url(payload) ) )
In Node.js:
const crypto = require('crypto');
const b64url = obj => Buffer.from(JSON.stringify(obj)).toString('base64url');
const header = b64url({ alg: 'HS256', typ: 'JWT' });
const payload = b64url({ sub: '1234567890', name: 'John Doe', iat: 1516239022 });
const signature = crypto.createHmac('sha256', 'your-256-bit-secret')
.update(`${header}.${payload}`)
.digest('base64url');
const token = `${header}.${payload}.${signature}`;
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
You can reproduce it by hand: paste eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ as the message and your-256-bit-secret as the key into the HMAC-SHA256 generator, then Base64url-encode the resulting bytes.
Because the input to the HMAC is the encoded text, any change to the header or payload, even reordering the keys or adding a space, changes the signature completely.
HS256 vs RS256 vs ES256
The alg header selects one of two families of algorithm.
| HS256 | RS256 | ES256 | |
|---|---|---|---|
| Type | Symmetric (HMAC-SHA256) | Asymmetric (RSA with SHA-256) | Asymmetric (ECDSA P-256 with SHA-256) |
| Signing key | Shared secret | RSA private key | EC private key |
| Verification key | The same shared secret | RSA public key | EC public key |
| Anyone who can verify can also forge | Yes | No | No |
| Signature size | 32 bytes | 256 bytes for a 2048-bit key | 64 bytes |
| Speed | Fastest | Slow to sign, fast to verify | Fast for both |
| Typical use | One service issues and verifies its own tokens | Identity providers (Auth0, Okta, Azure AD, Google) | Modern identity providers, mobile, constrained devices |
Use HS256 when the same application both issues and checks the tokens. It is simple and fast, but every party that verifies tokens holds the secret and could therefore mint tokens, so it does not suit multi-service setups. RFC 7518 requires the secret to be at least 256 bits; generate 32 random bytes and keep it in a secrets manager, not in source control.
Use RS256 or ES256 when tokens are issued by one system and verified by others: an identity provider and many APIs, or a public API whose clients must validate tokens. Only the issuer holds the private key; verifiers fetch the public key from a JWKS endpoint (/.well-known/jwks.json) and match it by kid. ES256 gives much smaller tokens and keys than RS256 for equivalent security and is now the usual choice for new deployments, while RS256 remains the most widely supported.
How to verify a JWT
Verification never "decrypts" anything. It recomputes or checks the signature and then checks the claims.
- Split the token into three segments at the dots and Base64url-decode the header.
- Decide the algorithm yourself. Your code should know whether it expects HS256 or RS256. Compare the header's
algto that expectation; do not let the token choose. - Select the key by
kidif you use asymmetric signing, or use your configured secret. - Check the signature over
header.payload. For HMAC, recompute and compare with a constant-time comparison. - Check the claims:
exphas not passed,nbfhas passed,issmatches your issuer, andaudnames your API. Allow a small clock skew (30 to 60 seconds) but no more. - Only then trust the payload.
A well-maintained library does steps 1 to 5 for you. With the jsonwebtoken package in Node.js:
const jwt = require('jsonwebtoken');
try {
const claims = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // never omit this
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
clockTolerance: 30
});
// claims.sub, claims.roles ...
} catch (err) {
// TokenExpiredError, JsonWebTokenError, NotBeforeError
}
In ASP.NET Core the equivalent is TokenValidationParameters with ValidateIssuer, ValidateAudience, ValidateLifetime and ValidateIssuerSigningKey all set to true.
Common verification mistakes
Accepting alg: none. RFC 7518 defines "alg": "none" for unsecured tokens with an empty signature. Several popular libraries once accepted it, so an attacker could take a real token, change the header to none, edit the payload and strip the signature. Modern libraries reject it unless explicitly enabled; you should still pass an explicit allow-list of algorithms.
Trusting the header's alg (key confusion). If a server verifies RS256 tokens with a public key but lets the token pick the algorithm, an attacker can change alg to HS256 and sign a forged payload using the public key as the HMAC secret. The server then "verifies" it with the same public key. The fix is the same: pin the algorithm in your code.
Not checking exp. Decoding a token is not validating it. A library's decode() function returns the payload of an expired token without complaint; only verify() checks the lifetime.
Not checking aud and iss. A token issued for https://reports.example.com should not be accepted by https://billing.example.com, even though the same identity provider signed both. Without an audience check, a token stolen from a low-value service unlocks every service that shares the issuer.
Decoding without verifying at all. Reading role from atob(token.split('.')[1]) and acting on it means anyone can grant themselves any role. Verify first, always.
Weak or reused HMAC secrets. Short, guessable secrets can be brute-forced offline from a single captured token. Use 32 or more random bytes, one secret per environment, and rotate it.
Comparing signatures with ==. String comparison leaks timing information. Use crypto.timingSafeEqual or the library's verify function.
Expiry, refresh tokens and revocation
Because a JWT is validated without a database lookup, the server cannot easily "log out" a token before it expires. The standard answer is to keep tokens short-lived and pair them with a refresh token:
1. Login → access token (15 minutes) + refresh token (days, stored server-side or as an opaque value)
2. Client calls the API with the access token
3. Access token expires → client sends the refresh token to the auth server
4. Auth server checks the refresh token has not been revoked, issues a new access token (and often a new refresh token)
5. Logout or compromise → revoke the refresh token; the access token dies within minutes
Typical lifetimes are 5 to 15 minutes for access tokens and days to weeks for refresh tokens, with refresh token rotation (each refresh invalidates the previous refresh token) so that a stolen one is detected on reuse. If you truly need instant revocation of access tokens, keep a deny-list keyed by jti, at which point you have reintroduced some server state.
Where to store tokens: cookies vs localStorage
localStorage or sessionStorage is easy to use from JavaScript, which is precisely the problem: any script running on the page, including one injected through an XSS flaw or a compromised third-party script, can read the token and send it elsewhere.
HttpOnly cookies cannot be read by JavaScript, so XSS cannot steal them directly. Set Secure (HTTPS only) and SameSite=Lax or Strict to blunt CSRF, and add a CSRF token for state-changing requests if you need SameSite=None.
res.cookie('access_token', jwt, { httpOnly: true, secure: true, sameSite: 'strict' });
For single-page applications the common recommendation is to keep the access token in memory only and the refresh token in an HttpOnly cookie, or to use a backend-for-frontend that holds the tokens server-side and gives the browser an ordinary session cookie. Remember cookies are limited to about 4 KB, so keep payloads small.
JWT vs session cookies
| Feature | JWT | Server-side session |
|---|---|---|
| Where state lives | In the token (client) | In a session store (server) |
| Lookup per request | None; verify signature | One session-store read |
| Scales across services | Easily; every service can verify | Needs a shared session store |
| Immediate revocation | Hard; wait for exp or keep a deny-list |
Easy; delete the session |
| Size per request | Hundreds of bytes to a few KB | A short session ID |
| Cross-domain / mobile / third-party APIs | Works well | Awkward |
JWTs suit APIs consumed by many clients, microservices, mobile apps and federated login. A traditional server-rendered web application on a single domain is often simpler and safer with plain sessions.
Frequently Asked Questions
What is a JWT?
A JSON Web Token is a compact string made of three Base64url-encoded parts separated by dots: a header naming the signing algorithm, a payload of JSON claims such as the user ID and expiry time, and a signature computed over the first two parts. It lets an API confirm that the claims were issued by a trusted party and have not been changed, without a database lookup on every request.
Is a JWT encrypted?
Not usually. A standard signed JWT (a JWS) is encoded, not encrypted: anyone who has the token can decode the header and payload and read every claim. The signature only proves integrity and origin. If the contents must be confidential, use an encrypted JWT (JWE, RFC 7516), send the token only over HTTPS, and keep sensitive data out of the payload altogether.
Can a JWT be decoded without the secret?
Yes. The header and payload are plain Base64url, so any tool, including our JWT Decoder, can display them without any key. The secret or private key is needed only to create a valid signature, and the secret or public key is needed to verify one. Decoding tells you what a token claims; verifying tells you whether to believe it.
What fields are in a JWT?
The header normally contains alg (algorithm), typ (JWT) and sometimes kid (key ID). The payload contains claims: the registered ones are iss, sub, aud, exp, nbf, iat and jti, and issuers add their own such as email, roles or scope. The third part is the signature, which is not JSON but raw signature bytes in Base64url form.
What is the JWT shared secret?
The shared secret is the key used by HMAC algorithms such as HS256. The issuer uses it to sign tokens and every verifier uses the same value to check them, so anyone who holds it can also forge tokens. It should be at least 256 bits of random data, stored in a secrets manager or environment variable, never embedded in client-side code, and rotated periodically. Asymmetric algorithms such as RS256 replace it with a private/public key pair so verifiers cannot forge tokens.
What is the JWT at_hash claim?
at_hash is an OpenID Connect claim found in ID tokens that were issued alongside an access token. It contains the Base64url encoding of the left-most half of a hash of the access token, using the hash function that matches the token's alg (SHA-256 for RS256 or ES256, so the left-most 128 bits). A client hashes the access token it received, compares it to at_hash, and thereby confirms the two tokens were issued together and the access token was not swapped.
How long should a JWT last?
Keep access tokens short: 5 to 15 minutes is common, and an hour is a reasonable upper limit, because a stolen token cannot be revoked before exp. Use a refresh token, valid for days to weeks and revocable server-side, to obtain new access tokens without asking the user to log in again. ID tokens from OpenID Connect are typically valid for an hour or less and are meant to be consumed immediately.
What is alg: none?
"alg": "none" is an algorithm value defined in RFC 7518 for unsecured tokens that have an empty signature. It exists for cases where integrity is guaranteed by other means, but in practice it is an attack vector: if a verifier accepts none, an attacker can strip the signature from any token, edit the claims and have them accepted. Always configure your library with an explicit list of permitted algorithms that excludes none.
Summary
- A JWT is
header.payload.signature, each part Base64url-encoded. The header and payload are readable by anyone; only the signature needs a key. - The signature for HS256 is
HMAC-SHA256(secret, header + "." + payload); RS256 and ES256 use a private key to sign and a public key to verify. - Verification means checking the signature with an algorithm you chose, then checking
exp,nbf,issandaud. - Keep access tokens short-lived, use rotating refresh tokens, store tokens in HttpOnly cookies or memory rather than
localStorage, and never put secrets in the payload.
Paste any token into the JWT Decoder to inspect its header and claims, or use the HMAC-SHA256 generator and HMAC Generator to reproduce a signature by hand.