JSON Web Tokens (JWTs) are base64-encoded tokens. While they are readable by anyone who has access to them, if implemented correctly, access is limited to only a small, trusted group of individuals — those who are meant to see them.
While most individuals who can view ID Tokens
or Access Tokens
may not fully understand what they are,
how to use them, or how to exploit them, it is important to consider the possibility of malicious intent from the few who do.
Take this JWT as an example:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNzQ1OTk4MTU3LCJleHAiOjE3NDYwMDE3NTd9.MSjuPRAS5uCRzqU7O_cCA0bVODpQtcqmxhFU0Ik37BY
This JWT contains the following payload:
{ "sub":"1234567890", "name":"John Doe", "iat":1745998157, "exp":1746001757 }
What prevents an attacker from modifying the sub
claim,
for example changing it from 1234567890
to 1234567891
?
Would this allow the attacker to access someone else's account? If an attacker were to alter the token, it would look like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkxIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNzQ1OTk4MTU3LCJleHAiOjE3NDYwMDE3NTd9.MSjuPRAS5uCRzqU7O_cCA0bVODpQtcqmxhFU0Ik37BY
At first glance, this token might appear valid. While this is an important reason to validate a JWT before trusting it, it is not the only reason. Other reasons for validation include:
At first glance, the OpenID Protocol may seem to have some weaknesses, but that’s not the case. JWTs are specifically designed to be both readable and secure. They not only contain metadata about a principal but also include the necessary data to validate the authenticity of that information.
Upon receiving a token in your API or web application, you must verify (and test) that the following measures are in place:
The primary mechanism that prevents a JWT from being tampered with is its signature. A JWT consists of three parts, separated by dots: the header, the payload, and the signature.
When a JWT is created, the header and payload are Base64Url-encoded, concatenated with a dot, and then signed.
For JWTs using RS256, this signature is created by hashing the header and payload with SHA-256 and then signing the hash using the private key of an RSA key pair.
The corresponding public key is published by the authorization server at the /jwks.json
endpoint.
The /jwks.json
endpoint may expose multiple public keys,
allowing key rotation and support for different keys used by various clients or services.
In the JWT header, there's a field called kid
(Key ID), which indicates which key was used to sign the token.
By selecting the appropriate public key using the kid
and verifying the token’s signature,
the recipient can ensure the token’s integrity and authenticity.
Ensuring that a token hasn’t been tampered with is only part of validating its authenticity. One of the most fundamental checks is verifying that the token was issued by the expected authorization server. In other words, an attacker could generate a perfectly valid token — complete with the required claims — using a different authorization server.
Every JWT includes an iss
(issuer) claim, which identifies the server that issued the token.
This value must be explicitly validated to ensure it matches the trusted authority you expect.
If it doesn’t, the token should be rejected, even if the signature is valid.
The aud
(audience) claim identifies the intended recipient of the token — typically a specific application or API.
When your application receives a token, it must ensure that the aud
value matches the identifier (such as a client ID or API URI)
it expects.
If this check is omitted, a token meant for one service could be incorrectly accepted by another. This is known as the "confused deputy problem," where a service unintentionally accepts a valid token that wasn't meant for it.
A mismatched or missing aud
value is grounds to reject the token,
even if the signature is valid and the token hasn’t expired.
The nbf
(not before) claim defines the earliest point in time at which a token becomes valid.
This allows issuers to ensure that a token cannot be used before a specific time.
To enforce this, compare the nbf
value with the current system time.
If the current time is before the nbf
value, the token is not yet valid and should be rejected.
This is especially useful in scenarios involving delayed execution or staged token rollouts.
The exp
(expiration) claim specifies when the token will expire.
After this point, the token must be considered invalid and rejected.
Expiration checks are one of the most critical parts of token validation. Allowing expired tokens could expose your system to replay attacks and unauthorized access.
As with the nbf
check, compare the current time to the exp
timestamp,
using a secure time source to prevent tampering or clock skew exploitation.
In some cases, especially with opaque tokens or in zero-trust environments,
you may want to perform an introspection check by calling the authorization server’s /introspect
endpoint.
This is typically used with reference tokens that cannot be validated offline, or when immediate revocation needs to be enforced.
The introspection endpoint returns metadata about the token, such as whether it is active, its issuer, subject, and expiry. It is a good practice for tokens with longer lifetimes or higher risk scopes.
Note that introspection adds network latency and a runtime dependency on the authorization server’s availability.
Most modern programming languages and application frameworks offer built-in tools for implementing JWT validation. Given the complexity of token validation, it is generally recommended to configure these tools properly rather than implementing them from scratch.