Security

JWT Tokens Explained: Structure, Signatures, Verification and Security

What a JSON Web Token is, its header, payload and signature, the standard claims, HS256 vs RS256, how to verify a JWT, common mistakes, and where to store tokens.

HandyUtils January 3, 2026 15 min read

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.

{
  "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.

  1. Split the token into three segments at the dots and Base64url-decode the header.
  2. Decide the algorithm yourself. Your code should know whether it expects HS256 or RS256. Compare the header's alg to that expectation; do not let the token choose.
  3. Select the key by kid if you use asymmetric signing, or use your configured secret.
  4. Check the signature over header.payload. For HMAC, recompute and compare with a constant-time comparison.
  5. Check the claims: exp has not passed, nbf has passed, iss matches your issuer, and aud names your API. Allow a small clock skew (30 to 60 seconds) but no more.
  6. 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, iss and aud.
  • 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.

Related Topics
jwt json web token jwt explained jwt decode jwt claims hs256 vs rs256 jwt signature jwt verification alg none at_hash refresh token jwt vs session jwt secret
Share this article

Continue Reading

Security
What Is HMAC? How Hash-Based Message Authentication Works

HMAC explained for developers: how it works, HMAC vs MAC, HMAC-SHA256 vs SHA-1, and how to verify signatures in your API.

Security
Understanding Hash Functions: MD5, SHA-1, SHA-256 Explained

A developer's guide to cryptographic hash functions: what they are, how they work, and choosing the right hash for your use case.

Security
Password Hashing & Salting Explained (How It Actually Works)

How password hashing and salting actually work — why fast hashes and plain encryption fail, and how bcrypt and Argon2 keep stored passwords safe.