How to verify a JWT correctly
Verification is a sequence, and skipping any step is a vulnerability. Here is the complete checklist, based on RFC 7519 and RFC 8725, with code for seven languages.
The checklist
- Parse strictly. Exactly three Base64URL segments; header and payload must be JSON objects. Reject duplicate member names.
- Choose the algorithm yourself. Configure the allowed algorithms (usually exactly one). Reject tokens declaring anything else, including
none. - Select the key. Use
kidto pick from your trusted key set — never fetch a key from a URL inside the token (jku,x5u) or trust an embeddedjwk. - Verify the signature. Stop here on failure; don't look at the claims.
- Validate time claims.
expin the future,nbfin the past, with a small leeway. - Validate iss and aud. Exact issuer match; your API's identifier must be in the audience.
- Check the token type. If you accept several kinds of JWT, use
typ(e.g.at+jwt) so an ID token can't be used as an access token. - Then authorize. Only now use
sub, scopes and roles.
Getting the right key
For HMAC tokens you need the shared secret, as bytes — watch the encoding. For RSA and ECDSA you need the issuer's public key; identity providers publish them as a JWKS at the jwks_uri in their discovery document. Cache the JWKS and refresh it when you see an unknown kid (with rate limiting).
Code in seven languages
These snippets verify an RS256 token against a JWKS URL, pin the algorithm, and validate issuer, audience and expiry.
JavaScript
import { createRemoteJWKSet, jwtVerify } from "jose";
const token = process.env.JWT;
const key = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
// Throws if the signature, algorithm, exp/nbf, issuer or audience is invalid.
const { payload, protectedHeader } = await jwtVerify(token, key, {
algorithms: ["RS256"], // pin the algorithm — never trust the token's alg header
requiredClaims: ["exp"],
issuer: "https://auth.example.com/",
audience: "api.example.com",
clockTolerance: 30, // seconds
});
console.log(protectedHeader, payload);
TypeScript
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
const token = process.env.JWT!;
const key = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
// Throws if the signature, algorithm, exp/nbf, issuer or audience is invalid.
const { payload, protectedHeader } = await jwtVerify(token, key, {
algorithms: ["RS256"], // pin the algorithm — never trust the token's alg header
requiredClaims: ["exp"],
issuer: "https://auth.example.com/",
audience: "api.example.com",
clockTolerance: 30, // seconds
});
const claims: JWTPayload = payload;
console.log(protectedHeader, claims);
Python
import os
import jwt # pip install "pyjwt[crypto]"
token = os.environ["JWT"]
jwks_client = jwt.PyJWKClient("https://auth.example.com/.well-known/jwks.json")
key = jwks_client.get_signing_key_from_jwt(token).key
# Raises jwt.InvalidTokenError subclasses on any failure.
claims = jwt.decode(
token,
key,
algorithms=["RS256"], # pin the algorithm — never trust the token's alg header
issuer="https://auth.example.com/",
audience="api.example.com",
leeway=30,
options={"require": ["exp"]},
)
print(claims)
Java
// com.nimbusds:nimbus-jose-jwt:10.x
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.*;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.*;
import java.net.URL;
import java.util.Set;
public class Verify {
public static void main(String[] args) throws Exception {
String token = System.getenv("JWT");
ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
// Only RS256 is accepted, regardless of the token's own header.
JWKSource<SecurityContext> keySource = JWKSourceBuilder.create(new URL("https://auth.example.com/.well-known/jwks.json")).retrying(true).build();
processor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource));
DefaultJWTClaimsVerifier<SecurityContext> claimsVerifier = new DefaultJWTClaimsVerifier<>(
"api.example.com", // required audience
new JWTClaimsSet.Builder().issuer("https://auth.example.com/").build(), // exact-match claims
Set.of("exp")); // required claims
claimsVerifier.setMaxClockSkew(30);
processor.setJWTClaimsSetVerifier(claimsVerifier);
JWTClaimsSet claims = processor.process(token, null);
System.out.println(claims.toJSONObject());
}
}
Go
package main
import (
"fmt"
"log"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/MicahParks/keyfunc/v3"
)
func main() {
tokenString := os.Getenv("JWT")
k, err := keyfunc.NewDefault([]string{"https://auth.example.com/.well-known/jwks.json"})
if err != nil {
log.Fatal(err)
}
token, err := jwt.Parse(tokenString, k.Keyfunc,
jwt.WithValidMethods([]string{"RS256"}), // pin the algorithm
jwt.WithExpirationRequired(),
jwt.WithIssuer("https://auth.example.com/"),
jwt.WithAudience("api.example.com"),
jwt.WithLeeway(30 * time.Second),
)
if err != nil {
log.Fatalf("invalid token: %v", err)
}
fmt.Println(token.Claims.(jwt.MapClaims))
}
C#
// dotnet add package Microsoft.IdentityModel.JsonWebTokens
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
var token = Environment.GetEnvironmentVariable("JWT");
using var http = new HttpClient();
var jwks = new JsonWebKeySet(await http.GetStringAsync("https://auth.example.com/.well-known/jwks.json"));
var parameters = new TokenValidationParameters
{
ValidAlgorithms = new[] { "RS256" }, // pin the algorithm
IssuerSigningKeys = jwks.GetSigningKeys(),
ValidIssuer = "https://auth.example.com/",
ValidAudience = "api.example.com",
RequireExpirationTime = true,
ClockSkew = TimeSpan.FromSeconds(30),
};
var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, parameters);
if (!result.IsValid) throw result.Exception;
foreach (var claim in result.Claims) Console.WriteLine($"{claim.Key}: {claim.Value}");
PHP
<?php
// composer require firebase/php-jwt
require 'vendor/autoload.php';
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
$jwt = getenv('JWT');
JWT::$leeway = 30; // seconds
// Validates signature, exp, nbf and iat; throws on failure.
$jwks = json_decode(file_get_contents('https://auth.example.com/.well-known/jwks.json'), true);
// parseKeySet uses 'RS256' for keys without an "alg" member.
$decoded = JWT::decode($jwt, JWK::parseKeySet($jwks, 'RS256'));
// php-jwt does not check iss/aud — do it explicitly.
if (($decoded->iss ?? null) !== 'https://auth.example.com/') {
throw new UnexpectedValueException('Invalid issuer');
}
if (!in_array('api.example.com', (array) ($decoded->aud ?? []), true)) {
throw new UnexpectedValueException('Invalid audience');
}
if (!isset($decoded->exp)) {
throw new UnexpectedValueException('Missing exp');
}
print_r($decoded);
Mistakes to avoid
- Calling a
decode()function (which skips verification) and trusting the result. - Passing
algorithms=[header.alg]— the attacker controls the header. - Skipping the audience check because the library made it optional.
- Using a large leeway to paper over clock problems.
- Logging full tokens: logs then contain working credentials.
Test your key and token in the browser before writing the code. The decoder generates these snippets pre-filled from your token.
Verify a token now