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

  1. Parse strictly. Exactly three Base64URL segments; header and payload must be JSON objects. Reject duplicate member names.
  2. Choose the algorithm yourself. Configure the allowed algorithms (usually exactly one). Reject tokens declaring anything else, including none.
  3. Select the key. Use kid to pick from your trusted key set — never fetch a key from a URL inside the token (jku, x5u) or trust an embedded jwk.
  4. Verify the signature. Stop here on failure; don't look at the claims.
  5. Validate time claims. exp in the future, nbf in the past, with a small leeway.
  6. Validate iss and aud. Exact issuer match; your API's identifier must be in the audience.
  7. 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.
  8. 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

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