Understanding JWT Validation: A Practical Guide with Code Examples
JSON Web Tokens (JWTs) offer a secure and standardized method for storing and exchanging data in JSON format. Despite their broad adoption in modern web development, a robust implementation of JWTs requires knowledge and careful handling to ensure optimal security. This brings us to the topic of JWT validation.
In this article, we’ll discuss:
- How do we ensure a JWT token can be trusted and hasn’t been tampered with?
- What does JWT validation involve, and which token elements are validated?
- How to perform JWT validation.
While JWTs can also be encrypted for confidentiality (see JSON Web Encryption, JWE), our focus here is on signed tokens (JSON Web Signature, JWS). Encrypted tokens keep the information they contain hidden from external parties. Signed tokens, on the other hand, do not hide the information, but offer evidence of data integrity through validation. That’s why the validation step is crucial when working with signed JWTs.
We'll use a JWT token issued by Criipto Verify to showcase the token structure and the elements that require validation.
A primer on JWTs
JWTs are widely used to handle identity, authentication, and authorization. JWT-based user authentication has become a standard practice, especially because it integrates so smoothly with mobile applications and SPAs. Another common case for JWTs is to utilize them as access tokens and ID tokens in OAuth2 and OpenID Connect flows.
Demonstration: a JWT token in an OpenID Connect flow
Consider an OpenID Connect flow, where a user logs into a web application with an electronic ID (eID). Criipto Verify acts as an OpenID provider for the login process: upon successful authentication, it issues an ID token formatted as a JWT to the client application. (More specifically, your domain at Criipto will act as an OpenID Provider.)
For instance, when a user logs in using Danish MitID, the application receives an ID token that might look like this:
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjhCREY3OUEzRkY5OTdFQTg1QjYyRjk1OUQzRDdCMzdFRDAyMjhFOTAifQ.eyJpc3MiOiJodHRwczovL2RlbW9zLXRlc3QuY3JpaXB0by5pZCIsImF1ZCI6InVybjpkZW1vczpjb29sLWRlbGl2ZXJ5LXJlYWN0IiwiaWRlbnRpdHlzY2hlbWUiOiJka21pdGlkIiwiYXV0aGVudGljYXRpb250eXBlIjoidXJuOmdybjphdXRobjpkazptaXRpZDpzdWJzdGFudGlhbCIsImF1dGhlbnRpY2F0aW9ubWV0aG9kIjoiYXBwOjE3MDUzMjQ5MjYxNTM6U1VCU1RBTlRJQUw6U1VCU1RBTlRJQUw6SElHSDpISUdIIiwiYXV0aGVudGljYXRpb25pbnN0YW50IjoiMjAyNC0wMS0xNVQxMzoyMjowNy44MDFaIiwibmFtZWlkZW50aWZpZXIiOiJiYWQwZmJiMmRmM2Q0Yzg3YTU5OGRjZDcyYTJlYWRjMCIsInN1YiI6IntiYWQwZmJiMi1kZjNkLTRjODctYTU5OC1kY2Q3MmEyZWFkYzB9Iiwic2Vzc2lvbmluZGV4IjoiYjRiMmE1ZjctMTM1Ni00YjhkLTliZjMtZTFlNjIzOWEzOTJlIiwibG9BIjoiU1VCU1RBTlRJQUwiLCJpYWwiOiJTVUJTVEFOVElBTCIsImFhbCI6IlNVQlNUQU5USUFMIiwiZmFsIjoiSElHSCIsInV1aWQiOiJkNWVhNjMzMS02ZGU0LTQ5ZTktODUyYy0xNDE1YjMxNTM3YzIiLCJiaXJ0aGRhdGUiOiIxOTk1LTAzLTAyIiwiZGF0ZW9mYmlydGgiOiIxOTk1LTAzLTAyIiwiYWdlIjoiMjgiLCJuYW1lIjoiS2FqIEFuZGVyc2VuIiwicmVmVGV4dEhlYWRlciI6IkxvZyBvbiBhdCBDcmlpcHRvIChURVNUKSIsInJlZlRleHRCb2R5IjoiTG9nIGluIHRvIENvb2wgRGVsaXZlcnkiLCJjb3VudHJ5IjoiREsiLCJpYXQiOjE3MDUzMjQ5NDUsIm5iZiI6MTcwNTMyNDk0NSwiZXhwIjoxNzA1MzI2MTQ1fQ.O7QilOzcqLqRFCr9ij_bPS_hLXtJSZjufTLpNhtU_Oy8FmtHl88ErUsOMKPAgo2WJoCLwrtgLGlPKVzcQ66iUZwboza7bEJF9tQFWAvK1XEw7Fce_TztjsJrxDl-vue4ImjkBZQl-IToPg4egrq6gqXYBcWtWk4xZXRSMtQ4wNOX3gtlAyhDmiAbWwjFNGFzDJYP--sZvxU0MsvDNGzJwz0_JoZGEQoDkUYABxyp9syVyJv6Hock5QGzPMI5JmgPkSPrnRGV2ZnYaytRn1CdQcKIijlh61vwvOxffZBHnNkGXJxim8lOUtEyhDQO4fmnh_bLc2cxOl1hdkGHkgjoRQ
What looks like a random string of characters is actually a compact, printable representation of a set of claims, accompanied by a cryptographic signature. The claims contain information about the authenticated user, and the signature ensures the authenticity of the token: Since the token is cryptographically signed, it’s easy to verify that it is legitimate and hasn’t been tampered with.
The process of validating the claims and the signature is known as token validation. This validation step is mandatory: If you do not validate the signature, you must not trust the contained end-user information.
Now, let's explore the structure of the JWT to gain a deeper understanding of what the validation entails, and which token elements are validated.
JWT structure overview
As defined in RFC 7519, a signed JWT consists of three segments separated by dots:
- Header
- Payload
- Signature
This creates the structure: header.payload.signature
The header and the payload sections contain JSON data encoded using Base64Url. So in essence, JWTs are bits of Base64Url-encoded JSON data with a cryptographic signature at the end. Anyone with access to a JWT can decode the token and see the information it contains. You can experiment with decoding on jwt.io – an interactive playground for learning more about JWTs. Feel free to copy the token above and see the results. Note what happens when you edit the contents (Hint: Any alterations to the content will invalidate the signature).
Now, let's break down each segment to understand its role.
Header
The Header contains information about the token type and the algorithm used for signature generation. The most common signature algorithms for JWTs are RS256 (RSA using SHA256) and HS256 (HMAC using SHA256). JWTs can be signed using a shared secret (with the HMAC algorithm) or the private key from a public-private key pair (with RSA).
The decoded header from our token looks like this:
{
"typ": "JWT",
"alg": "RS256",
"kid": "8BDF79A3FF997EA85B62F959D3D7B37ED0228E90"
}
The properties here are:
- “typ” for the token type
- “alg” for the signature algorithm (Criipto Verify uses RSA to sign JWTs)
- "kid" (key ID) identifies a public key that should be used for verification according to the RSA algorithm.
We’ll need this information to perform token validation.
Payload
The payload is central to a JWT: It contains information the token is meant to transport. The claims in the payload provide data about the user who logged in, such as their identity, role, or system permissions. Read more about JWT claims in our previous blog post.
There are three types of JWT claims:
Registered claims: These are defined by the JWT specification to ensure interoperability with third-party applications. The registered claims are not mandatory but recommended. Some registered claims are: iss (issuer), exp (expiration time), sub (subject), aud (audience), and others. Registered claims are useful for some common operations performed with JWTs, and they play a role in token validation as demonstrated in the next session.
Public claims: These refer to non-registered claims defined at will by the parties using JWTs. Public claims should be universally collision-resistant: either registered with the IANA "JSON Web Token Claims" registry or have a collision-resistant name.
Private claims: Similarly defined by the parties using JWTs, Private claims differ from Public claims in that they are not required to be collision-resistant. In our example token, identityscheme and authenticationtype are Private claims.
The decoded payload from our token looks like this (shortened for brevity, but you can see it in full using jwt.io):
{
"iss": "https://demos-test.criipto.id",
"aud": "urn:demos:cool-delivery-react",
"identityscheme": "dkmitid",
"authenticationtype": "urn:grn:authn:dk:mitid:substantial",
"authenticationinstant": "2024-01-15T13:22:07.801Z",
"nameidentifier": "bad0fbb2df3d4c87a598dcd72a2eadc0",
"sub": "{bad0fbb2-df3d-4c87-a598-dcd72a2eadc0}",
"sessionindex": "b4b2a5f7-1356-4b8d-9bf3-e1e6239a392e",
"loA": "SUBSTANTIAL",
"uuid": "d5ea6331-6de4-49e9-852c-1415b31537c2",
"birthdate": "1995-03-02",
"dateofbirth": "1995-03-02",
"age": "28",
"name": "Kaj Andersen",
"country": "DK",
"iat": 1705324945,
"nbf": 1705324945,
"exp": 1705326145
}
Signature
When an OpenID Provider issues a token, it signs it with a cryptographic signature.
The signature is generated by combining the base64URL-encoded header and payload segments with a secret or private key (depending on the chosen algorithm).
For RSA256:
RSA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
private_key
)
For HMAC256:
HMAC256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
As shown above, JWTs can be signed using either a shared secret (with HMAC) or a private key (with RSA).
The exact process of signing involves taking a hash of the signing input and then signing that hash. This article offers an excellent detailed explanation of the signing process if you want to fill in any gaps in your understanding.
The primary goal of the signature is to establish the authenticity of the JWT, confirming that its data hasn't been tampered with. In other words, the information in the token can be verified and trusted exactly because the token is digitally signed.
It's important to understand that while a signature ensures data integrity, it doesn't prevent others from viewing the content of the token. Therefore, sensitive information like user passwords should never be transmitted in a JWT; everything in the header and payload can and should be safe for public ‘consumption.’
As mentioned earlier, JWTs issued by Criipto Verify are signed using RS256. RSA, being an asymmetric algorithm, employs pairs of cryptographically linked keys: one public and one private. Criipto Verify holds the private key for signature generation, while the application consuming JWTs can retrieve a corresponding public key from the metadata endpoint and use it to validate the signature.
Public keys are available at jwks_uri from the OpenID Connect Discovery endpoint. JWKS stands for JWK Set, and represents a set of JSON Web Keys. For our example token, the jwks_uri is: https://demos-test.criipto.id/.well-known/jwks
Given that keys undergo changes (key rotation), it's recommended to use the endpoint for dynamic key retrieval.
Additionally, when keys are included in the JWT header, it's essential to check that they belong to the expected issuer. Only trust tokens from issuers that are pre-registered with your application.
When should you validate a JWT?
You must always validate an incoming JWT. This is the best practice even if you're operating within an internal network where the authorization server, client, and resource server are not connected over the Internet.
For OpenID Connect flows, the process of creating and encoding a JWT token is handled by the OpenID Provider, while validation must be done on the server side of the client application. Optionally, you can also perform client-side validation: it doesn’t replace the mandatory checks in the backend, but may serve as a hint, and potentially a way to reduce server load by not sending invalid JWTs from the frontend to the backend.
In projects that rely on JWT-based authentication, token validation is typically managed by a dedicated library or middleware, as we’re about to illustrate.
JWT validation step-by-step
In this section, we'll walk through the JWT validation process, once again using the Criipto Verify-issued ID token as an example. While certain details, such as where to obtain JWT keys, are specific to our case, the overall combination of steps applies to all signed JWTs.
Here are the key steps for performing JWT validation:
- Retrieve and parse the JSON Web Key Set (JWKs)
- Decode the token
- Verify the claims
- Verify the signature
The order in which these steps are executed can vary. For instance, the raw JWT may be decoded and parsed first to extract the `iss` claim, allowing for early rejection of tokens from non-trusted issuers. Similarly, the order of verifying claims and the signature will depend on the chosen library. In essence, the implementing library has the autonomy to establish the order of these steps, provided that it fulfills all of them.
Retrieve JSON Web Keys
Obtain JSON Web Key Set (JWKSet / JWKs) from the OIDC discovery endpoint for your domain at Criipto. The OIDC discovery endpoint contains metadata about your token issuer. You need the jwks_uri that allows you to access JWKs. Ideally, your application should periodically check and cache these keys.
Decode the token
As mentioned, the tokens are Base64URL-encoded, which means you need to decode the token before validating it.
Verify claims
Verify the following claims in the JWT Header:
- alg
The best practice is to always check the value of the alg claim against a positive-list of algorithms your system accepts. This helps prevent potential attacks where someone manipulates the token to make you use a different, probably less secure algorithm for signature verification. There is also a known attack vector that exploits the "none" value in the alg claim. - kid
Claims pointing to public keys also require special attention. If these claims are spoofed, they can direct your service to forged verification keys, tricking it into accepting malicious tokens. Double-check that the keys (or any URLs) in the header correspond to the token's issuer (the iss claim) or have the values you expect.
Verify the following claims in the JWT Payload:
- iss(issuer)
The issuer claim should match the identifier of your OpenID Provider. With OpenID Connect, the issuer must be represented as a URL using the HTTPS scheme. For example, in our scenario, the issuer is our domain at Criipto: https://demos-test.criipto.id
We recommend cross-referencing the claim with an approved list of trusted issuers and always validating that the cryptographic keys used to sign the token actually belong to the issuer. Broadly speaking, when working with JWTs, it’s imperative to confirm that the token originates from the expected issuer. This is especially important if your application retrieves the JWKS dynamically.
- aud(audience)
Always check the aud claim and verify that the token was issued to an audience that includes your application. You should reject any request that contains a token intended for different audiences.
An ID token will always contain the client ID in the aud claim. In our scenario, the aud claim will be the Client ID of our Criipto Verify application. - Time-based claims
The iat (issued at time) claim indicates when this ID token was issued, expressed in Unix time. You can use this claim to reject tokens that you deem too old to be used by your application.
The exp (expiry time) claim is the time at which this token will expire, expressed in Unix time. You should make sure that this time has not already passed.
The nbf ("not-before" time) claim: the token should be rejected if the current time is before the time in the nbf claim.
A common pitfall is for client applications not to allow for some form of clock-skew when validating these claims. Time server differences would then lead to tokens appearing invalid. A value of 5 minutes for clock skew is typically recommended.
Verify the signature
To recap: with RS256, as in our case, JWTs are signed with a private key when generated and validated with a public key upon receipt. Validation is crucial for ensuring that the tokens haven't been modified in transit.
You verify the token's signature by matching the key used to sign it with one of the keys you retrieved from the JWKs endpoint. Specifically, each key is identified by a kid attribute, which corresponds to the kid claim in the token header.
If the kid claim doesn't match, and you use some form of caching for JWKSets, it’s possible that the signing keys may have changed (key rollover). Retrieve data from the jwks_uri endpoint in the OIDC metadata and check the keys again (you should perform this re-fetch exactly once to avoid infinite loops in token validation logic).
Implementing validation
There are several approaches to performing JWT validation:
1. Existing middleware
You can leverage existing middleware of your web framework. For instance, ASP.NET provides a dedicated JWT middleware that seamlessly integrates with its overarching authentication logic.
2. Third-party libraries
We strongly advise selecting a well-established library for your platform to perform token validation. You can find an extensive list of libraries for various languages at jwt.io. When making your choice, confirm that the library supports the signing algorithm used by your JWTs and check which claims it validates. Most third-party libraries offer a flexible verification method with customizable arguments, which lets you tailor the verification process to your needs.
You should carefully follow all instructions on how to use the selected library, as default settings may introduce potential security risks.
3. Manual implementation (not recommended):
While RFC 7519 > 7.2 Validating a JWT outlines essential checks for validating a token, manual JWT validation is strongly discouraged due to the risk of improper implementation leading to security vulnerabilities.
It is highly recommended that you use middleware or one of the existing third-party libraries to validate JWTs. Below, we've included code samples with examples in various languages.
Code samples
Python
For instance, with PyJWT library, a token can be verified with the decode method, passing along the token and retrieving the signing key from the JWKS endpoint. PyJWKClient automatically handles key resolution based on the kid present in the JWT header.
import jwt
from jwt import PyJWKClient
token = "your_jwt_token"
url = "https://demos-test.criipto.id/.well-known/jwks"
optional_custom_headers = {"User-agent": "custom-user-agent"}
# Initialize PyJWKClient and fetch the signing key from JWKS
jwks_client = PyJWKClient(url, headers=optional_custom_headers)
signing_key = jwks_client.get_signing_key_from_jwt(token)
# Decode the token using the retrieved signing key
data = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience="your_audience"
)
print(data)
NodeJS
With jose library, use the jwtVerify function. To obtain the public keys for verification, you can fetch the JSON Web Key Set from a JWKS endpoint with createRemoteJWKSet. The code sample below also includes optional parameters, such as algorithm, and specific claims.
const jose = require('jose');
const token = 'your_jwt_token';
const JWKS = jose.createRemoteJWKSet(new URL('https://demos-test.criipto.id/.well-known/jwks'));
async function verifyToken() {
try {
const decodedToken = await jose.jwtVerify(token, JWKS, {
algorithms: ['RS256'],
issuer: 'https://demos-test.criipto.id',
audience: 'your_audience',
});
console.log('Token is valid:', decodedToken);
} catch (error) {
console.error('Error verifying token:', error);
}
}
verifyToken();
C#
With the Microsoft.IdentityModel.JsonWebTokens library in C#, employ the JwtSecurityTokenHandler class and use the ValidateToken method. The code sample below includes an asynchronous GetJsonWebKeySetAsync method, which uses HttpClient to fetch the JSON Web Key Set from a specified endpoint. The TokenValidationParameters include settings for validating the issuer (ValidIssuer) and audience (ValidAudience).
using System;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using System.Net.Http;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var token = "your_jwt_token";
var jwksEndpoint = "https://demos-test.criipto.id/.well-known/jwks.json";
var jsonWebKeySet = await GetJsonWebKeySetAsync(jwksEndpoint);
var handler = new JwtSecurityTokenHandler();
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (s, securityToken, identifier, parameters) => jsonWebKeySet.FindKey(identifier),
ValidIssuer = "https://demos-test.criipto.id",
ValidAudience = "your_audience"
};
try
{
var claimsPrincipal = handler.ValidateToken(token, validationParameters, out _);
Console.WriteLine("Token is valid.");
}
catch (SecurityTokenException)
{
Console.WriteLine("Invalid token.");
}
}
static async Task<JsonWebKeySet> GetJsonWebKeySetAsync(string jwksEndpoint)
{
using (var httpClient = new HttpClient())
{
var json = await httpClient.GetStringAsync(jwksEndpoint);
return new JsonWebKeySet(json);
}
}
}
Summary
The security of JWTs depends greatly on how tokens are used and validated.
It's important to emphasize that JWT is not a protocol but only a message format. The RFC serves as a guide for structuring messages and incorporating layers of security to protect the integrity of the message. It’s up to the implementer to follow best practices when working with JWTs. Therefore, it’s crucial to use a reliable tool or library to encode and decode the tokens and check their validity.