A JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties, and it’s often used for authentication and information exchange.
Let’s see a JWT in action. Imagine a user logs into your application. The server validates their credentials and, instead of a session ID, issues a JWT. This token is then sent to the client (e.g., a web browser). The client stores this JWT and includes it in the Authorization header of subsequent requests to protected resources:
GET /api/protected/resource HTTP/1.1
Host: example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The server receives this request, extracts the JWT, and verifies its authenticity and integrity before granting access to the protected/resource.
A JWT has three parts, separated by dots (.): a header, a payload, and a signature.
The header typically contains metadata about the token, such as the signing algorithm used. It’s a JSON object, Base64Url encoded.
{
"alg": "HS256",
"typ": "JWT"
}
Here, alg specifies the signing algorithm (HMAC SHA256 in this case), and typ indicates the type of token (JWT).
The payload contains the claims, which are statements about an entity (typically, the user) and additional data. It’s also a JSON object, Base64Url encoded.
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
Common claims include:
iss(issuer): The principal that issued the JWT.sub(subject): The principal that is the subject of the JWT.aud(audience): The recipients that the JWT is intended for.exp(expiration time): The time after which the JWT MUST NOT be accepted for processing.nbf(not before): The time before which the JWT MUST NOT be accepted for processing.iat(issued at): The time at which the JWT was issued.jti(JWT ID): Provides a unique identifier for the JWT.
The signature is used to verify that the sender of the JWT is who it says it is and to ensure that the message wasn’t changed along the way. It’s created by taking the encoded header, the encoded payload, a secret (for symmetric algorithms like HS256) or a private key (for asymmetric algorithms like RS256), and signing them using the algorithm specified in the header.
For example, if using HMAC SHA256 (HS256) with a secret your-256-bit-secret:
base64UrlEncode(header) + "." + base64UrlEncode(payload)is created.- This string is signed using
HMACSHA256(secret, string_to_sign). - The result is Base64Url encoded to form the signature.
The entire token is then encodedHeader + "." + encodedPayload + "." + signature.
The primary problem JWTs solve is stateless authentication. Instead of a server needing to maintain a session store (e.g., a database table mapping session IDs to user data), the necessary user information can be embedded directly within the JWT itself. When a user logs in, the server creates a JWT containing their user ID, roles, and an expiration time, signs it with a secret key, and sends it to the client. The client then presents this token with each request. The server, using the same secret key, can verify the token’s signature and immediately trust the claims within it without needing to consult a database. This significantly reduces server load and improves scalability.
When verifying a JWT, the process is essentially the reverse of signing. The server receives the token, splits it into its three parts, decodes the header and payload, and then:
- Verifies the signature: It takes the encoded header, the encoded payload, and the same secret key used for signing. It then computes the signature using the algorithm specified in the header and compares it to the signature provided in the token. If they don’t match, the token is invalid.
- Validates claims: It checks if claims like
exp(expiration time) andnbf(not before) are valid. If the token has expired, it’s rejected.
Libraries like jsonwebtoken in Node.js make this straightforward:
const jwt = require('jsonwebtoken');
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const secretKey = 'your-256-bit-secret'; // MUST be the same secret used for signing
try {
const decoded = jwt.verify(token, secretKey);
console.log('Token is valid:', decoded);
} catch (err) {
console.error('Token verification failed:', err.message);
}
Security Pitfalls:
- Storing Secrets Insecurely: The secret key used for signing and verifying JWTs is paramount. If an attacker obtains this secret, they can forge any token, impersonating any user. Never embed secrets directly in client-side code. Store them in environment variables or a secure secrets management system on the server. For example, in Node.js, use
process.env.JWT_SECRET. - Not Verifying the Signature: Some implementations might forget to verify the signature or, worse, accept tokens without a signature (e.g., by setting the algorithm to
none). Always, always verify the signature. If a library allowsalg: "none", ensure it’s disabled or handled with extreme caution. - Using Weak Secrets: For symmetric algorithms like HS256, the secret must be strong and sufficiently long. A common mistake is using short, predictable secrets like
"secret"or"password". Use a cryptographically secure random string generator to create secrets of at least 32 bytes (256 bits). - Ignoring Expiration (
exp): JWTs are designed to expire. Failing to check theexpclaim means a compromised token remains valid indefinitely. Ensure your verification logic always checks the expiration time. - Not Validating
aud(Audience): If your API is consumed by multiple clients (e.g., a web app and a mobile app), and they all use the same JWT issuer, a malicious client could potentially intercept a token intended for another client and reuse it. Specifying and verifying theaudclaim ensures the token is intended for the specific recipient. - Sending Sensitive Data in the Payload: While the payload is Base64Url encoded, it is not encrypted. Anyone who intercepts the token can decode the payload and read its contents. Do not put sensitive information like passwords, credit card numbers, or personally identifiable information directly into the payload. For sensitive data, use encryption or store references to the data.
- Token Replay Attacks: If a token is stolen, it can be used by an attacker until it expires. Strategies to mitigate this include:
- Using short expiration times (e.g., 15-60 minutes) and implementing refresh tokens.
- Maintaining a blacklist of revoked tokens, though this reintroduces statefulness.
- Including a unique
jti(JWT ID) claim and checking against a short-lived cache of used IDs.
The most insidious pitfall is often related to algorithm confusion. Some JWT libraries have historically had a default behavior where if the alg in the header is none, the signature verification is skipped. An attacker could then craft a token with {"alg": "none", "typ": "JWT"} in the header and an arbitrary payload, and if the server doesn’t explicitly check for and reject alg: "none", it would accept this token as valid, effectively granting unauthorized access. Always explicitly set the expected algorithm during verification.
The next hurdle you’ll likely face is managing the lifecycle of these tokens, particularly when dealing with short expirations and the need for continuous user access.