JWT Authentication Explained for Beginners
JSON Web Tokens (JWTs) are the backbone of authentication in most modern REST APIs and single-page apps. They let a server verify who a user is without storing session state in a database. This guide breaks down exactly what's inside a JWT and how it flows through a login and API request.
The three parts of a JWT
A JWT is a single string made of three parts separated by dots: header.payload.signature. Each part is Base64url-encoded — a URL-safe variant of Base64 that swaps +// for -/_ and drops the padding.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJ1c2VySWQiOiI0MiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc4MzY0ODAwMH0
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
// header: { "alg": "HS256", "typ": "JWT" }
// payload: { "userId": "42", "role": "admin", "exp": 1783648000 }
// signature: HMAC-SHA256(header + "." + payload, SECRET_KEY)Why Base64url and not plain Base64?
JWTs are frequently passed in URLs, HTTP headers, and cookies. Standard Base64's + and / characters have special meaning in URLs and would need percent-encoding. Base64url avoids that entirely by using only URL-safe characters, so the token can be dropped into a header or query string with zero extra encoding.
function base64UrlEncode(obj) {
const json = JSON.stringify(obj);
const base64 = btoa(json);
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, ''); // padding is dropped, not needed for JWTs
}
base64UrlEncode({ userId: '42', role: 'admin' });
// → 'eyJ1c2VySWQiOiI0MiIsInJvbGUiOiJhZG1pbiJ9'The signature — what it actually protects
The signature is computed by hashing the header and payload together with a secret key known only to the server (for HMAC algorithms like HS256) or a private key (for RSA/ECDSA algorithms like RS256). It's important to understand what this does and doesn't do:
- Protects against tampering — if anyone changes the payload (e.g.
role: admin), the signature no longer matches and verification fails. - Does NOT hide the contents — the header and payload are just Base64url, not encrypted. Anyone can decode and read them without the secret key.
- Never put sensitive data (passwords, credit card numbers) in a JWT payload — treat it as visible to the client and anyone who intercepts it.
The full authentication flow
- User submits email/password to
POST /login. - Server verifies credentials against the database.
- Server creates a JWT payload with claims (
userId,role,exp) and signs it with a secret key, returning the token to the client. - Client stores the token (memory, httpOnly cookie, or localStorage) and attaches it to every subsequent request:
Authorization: Bearer <token>. - On each request, the server verifies the signature and checks
exp— no database session lookup needed, which is why JWTs scale well across stateless services. - When the token expires, the client either re-authenticates or uses a longer-lived refresh token to obtain a new access token.
// Node.js server — issuing and verifying a JWT with jsonwebtoken
import jwt from 'jsonwebtoken';
// Issue a token at login
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// Verify on protected routes
app.get('/api/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
res.json({ userId: payload.userId });
} catch (err) {
res.status(401).json({ error: 'Invalid or expired token' });
}
});Common beginner mistakes
- Using
jwt.decode()instead ofjwt.verify()for access control — decode does not check the signature at all. - Storing sensitive personal data in the payload, assuming it's hidden.
- Never setting an expiry (
exp), so a stolen token stays valid forever. - Storing the secret key in source control instead of an environment variable.
Frequently Asked Questions
A JWT has three dot-separated parts: the header (algorithm and token type), the payload (claims/data like user ID and expiry), and the signature (used to verify the token was not tampered with). All three parts are Base64url-encoded.
No, by default a standard JWT is only Base64url-encoded and signed, not encrypted. Anyone can decode the header and payload and read the claims. The signature only proves the token has not been altered — it does not hide the contents. Use JWE if you need actual encryption.
The user logs in with credentials, the server verifies them and issues a signed JWT, the client stores the token and sends it in the Authorization header on future requests, and the server verifies the signature on each request to authenticate without a database lookup.