You open your browser's developer tools, inspect an API request, and find an authorization header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
The API responds with:
HTTP/1.1 401 Unauthorized
Your first question is probably:
Has the token expired, or is something else wrong?
You could copy the token, decode its payload, and inspect the expiration claim.
But there's an important security distinction that every developer and API tester should understand:
A JWT that decodes successfully is not necessarily a valid or trustworthy token.
Let's investigate what JWTs contain, how to troubleshoot authentication failures, and when decoding is useful versus when signature verification is essential.
1. What exactly is a JWT?
JWT stands for JSON Web Token.
A common signed JWT format contains three parts separated by periods:
HEADER.PAYLOAD.SIGNATURE
For example, a JWT might look like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
This example comes from the JWT standard, RFC 7519.
The three segments have different responsibilities.
| Segment | Purpose |
|---|---|
| Header | Describes token metadata, including the declared signing algorithm |
| Payload | Contains claims about the token's subject and other information |
| Signature | Provides integrity and authenticity evidence when correctly verified |
The header and payload are commonly JSON objects encoded using Base64URL.
They are not automatically encrypted.
Anyone possessing the token can generally decode those two parts.
That is why you should never place confidential information inside a normal signed JWT merely because its contents look unreadable.
2. Decoding a JWT is easy
Let's decode the sample token using JavaScript.
We will only inspect its contents.
We will not claim to verify its authenticity.
For this example, use a modern JavaScript runtime that supports atob() and TextDecoder.
const token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." +
"SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
function decodeBase64UrlJson(segment) {
const base64 = segment
.replace(/-/g, "+")
.replace(/_/g, "/");
const padding =
"=".repeat((4 - (base64.length % 4)) % 4);
const binary = atob(base64 + padding);
const bytes = Uint8Array.from(
binary,
char => char.charCodeAt(0)
);
return JSON.parse(
new TextDecoder("utf-8", {
fatal: true
}).decode(bytes)
);
}
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Expected a three-part JWT");
}
const header = decodeBase64UrlJson(parts[0]);
const payload = decodeBase64UrlJson(parts[1]);
console.log(header);
console.log(payload);
The decoded header is:
{
"alg": "HS256",
"typ": "JWT"
}
The decoded payload is:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
Notice something important.
We decoded the header and payload without using a secret key.
This is expected behavior.
Decoding reveals what the token says. It does not establish whether those statements are true.
Our example function is a learning aid, not a security validator or complete JWT parser.
3. Why decoding alone is dangerous
Imagine an API receives a token containing:
{
"sub": "user-123",
"role": "admin"
}
A developer might decode the payload and use the role value to grant administrative access.
That would be unsafe.
A token's contents can be modified or forged.
Without proper cryptographic verification, the server cannot assume the claims came from a trusted issuer.
For example, this is insecure application logic:
// INSECURE EXAMPLE — DO NOT USE FOR AUTHORIZATION
const payload = decodeBase64UrlJson(
token.split(".")[1]
);
if (payload.role === "admin") {
// Never grant privileges on this basis.
}
The application trusts unverified data.
An attacker may create a token that claims any role they want.
The important defense is to verify the token using a trusted cryptographic key and enforce the application's expected token requirements.
4. What does signature verification actually do?
JWT signatures are intended to protect the signed contents against unauthorized modification.
For example, when a token uses HS256, a trusted issuer signs the token using a shared secret.
When a token uses RS256, it is typically signed with a private key and verified with the corresponding public key.
Verification checks whether the signature is valid under the expected algorithm and trusted key.
But signature verification is only part of secure token handling.
Applications may also need to validate:
- Issuer
- Audience
- Expiration
- Not-before time
- Token type
- Required claims
- Accepted algorithms
- Revocation or session state
A token with a mathematically valid signature may still be inappropriate for a particular application.
For example, it may have been issued for a different audience.
Cryptographic validity and authorization are not the same thing.
5. Never trust the algorithm declared by the token
A JWT header might contain:
{
"alg": "HS256"
}
Or:
{
"alg": "none"
}
The token itself is untrusted input.
Your API should not blindly let an incoming token dictate which algorithms are acceptable.
Instead, configure your verification system to accept only the algorithms appropriate for your issuer and application.
For example, if your service expects RS256 tokens from a trusted identity provider, it should not silently switch to another algorithm because the incoming token requests it.
The expected algorithm, key selection, issuer, and audience should come from trusted application configuration.
OWASP specifically warns against relying on untrusted JWT header information to determine the verification algorithm.
6. How JWT expiration works
Now consider a token payload containing:
{
"sub": "user-123",
"iat": 1792886400,
"exp": 1792890000
}
Two fields matter here:
-
iat: Issued at -
exp: Expiration time
JWT time claims use NumericDate values, measured in seconds since the Unix epoch.
JavaScript's Date.now() returns milliseconds.
That difference can introduce subtle bugs.
Incorrect expiration check
const exp = 1792890000;
const expired = Date.now() >= exp;
This compares milliseconds with seconds.
The result is not meaningful.
Correct unit comparison
const exp = 1792890000;
const expirationMilliseconds = exp * 1000;
const expired =
Date.now() >= expirationMilliseconds;
console.log(expired);
Now both values use milliseconds.
However, this is only a comparison of an expiration number.
It does not authenticate the token.
A malicious token could contain an expiration date far in the future.
Always perform actual token verification using a trusted system before treating expiration claims as authoritative.
7. Understand the important registered claims
JWTs can contain several standard claims.
Here are seven commonly encountered ones.
| Claim | Meaning |
|---|---|
iss |
Issuer |
sub |
Subject |
aud |
Audience |
exp |
Expiration time |
nbf |
Not before |
iat |
Issued at |
jti |
JWT identifier |
For example:
{
"iss": "https://auth.example.com",
"sub": "user-123",
"aud": "orders-api",
"iat": 1792886400,
"nbf": 1792886400,
"exp": 1792890000,
"jti": "token-abc-123"
}
This payload claims:
- The issuer is an authentication service.
- The subject is
user-123. - The intended audience is
orders-api. - The token has issuance, validity-start, and expiration times.
- The token has an identifier.
But remember: before verification, these are merely claims.
The decoder cannot establish their authenticity.
8. Debugging a 401 Unauthorized response
Suppose your automated API test suddenly starts failing:
GET /api/orders
HTTP/1.1 401 Unauthorized
Your request includes an access token.
You suspect an expiration issue.
A structured investigation might look like this.
Step 1: Confirm the token was sent
Check whether your request contains an authorization header.
For example:
Authorization: Bearer <access-token>
Do not print a real token into publicly accessible logs.
Step 2: Check the token's structure
A common compact signed JWT contains three dot-separated segments.
However, not every access token is a JWT.
Some systems use opaque tokens that cannot be decoded locally.
Encrypted JWTs can also have a different structure.
Step 3: Inspect the claims
For a suitable test token, inspect exp, nbf, iss, and aud.
Ask whether the values are consistent with your expectations.
Step 4: Confirm the server's validation rules
Check the expected issuer, audience, algorithm, and trusted keys.
A failure can occur even when the expiration time appears reasonable.
Step 5: Check clock-related issues
A mismatch between application and identity-provider clocks can affect time-based checks.
Some verification systems allow a small, explicitly configured clock tolerance.
Step 6: Review the authentication workflow
Perhaps the token was never refreshed, the wrong environment was used, or a session was revoked.
Don't assume every 401 response is caused by expiration.
The response and the server-side verification logs should guide the investigation.
9. A browser-based way to inspect JWT claims
For quick debugging, writing a temporary decoding script every time can become repetitive.
I maintain ToolZenHub, a collection of practical browser-based utilities.
One of them is a JWT Decoder:
https://toolzenhub.in/tools/jwt-decoder/
It is designed to help developers inspect token contents without confusing decoding with signature verification.
Here's a practical walkthrough.
Step 1: Open the JWT Decoder
Visit the link above.
Step 2: Load a safe example
Select Load an example, or paste a synthetic, expired, or otherwise approved test token.
Avoid copying live production access tokens into debugging tools unless your security policies explicitly permit that use.
Step 3: Decode the token
Select Decode.
The tool displays the readable header and payload.
Step 4: Inspect registered claims
The tool explains standard claims such as:
iss
sub
aud
exp
nbf
iat
jti
This saves the effort of repeatedly converting timestamp claims by hand.
Step 5: Review time information
The tool shows supported time claims as:
- UTC date and time.
- Date and time in your device's local zone.
- Relative time information.
It also identifies values that look like milliseconds instead of the expected JWT seconds.
Step 6: Remember the verification boundary
The tool explicitly labels the signature as not verified.
This is intentional.
It reads token content. It does not authenticate the token, verify its signature, or confirm that an API should accept it.
The tool decodes tokens inside the browser without uploading the input token to ToolZenHub.
That reduces one category of data transfer, but does not make it appropriate to inspect sensitive production tokens on an unapproved device.
10. Why a decoder should not claim that a token is valid
Suppose you decode a token and see:
{
"sub": "user-123",
"role": "admin",
"exp": 4102444800
}
The expiration corresponds to a future date.
Does this mean the token is valid?
No.
The token could have:
- An invalid signature.
- An untrusted issuer.
- The wrong audience.
- An unsupported algorithm.
- A revoked session.
- Forged claims.
A decoder can show what the token claims about itself.
It cannot establish the token's trustworthiness without performing the required verification.
That is why the ToolZenHub decoder does not present its decoded results as proof of validity.
11. Testing JWT-protected APIs with Playwright
Let's consider an API test.
Suppose your application obtains an access token through a supported test authentication workflow.
You can send it with Playwright:
const { test, expect } = require("@playwright/test");
test("authenticated API request", async ({
request
}) => {
const token = process.env.TEST_ACCESS_TOKEN;
if (!token) {
throw new Error(
"TEST_ACCESS_TOKEN is not configured"
);
}
const response = await request.get(
"/api/profile",
{
headers: {
Authorization: `Bearer ${token}`
}
}
);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body).toHaveProperty("id");
});
This example assumes:
- A valid base URL is configured.
- The endpoint exists.
- The token is appropriate for the target environment.
- The API response contains an
idproperty.
Do not hardcode live credentials into the test source.
Use appropriate secret-management mechanisms for CI and development environments.
And don't print the token into test reports.
What should you test?
For a JWT-protected API, useful test scenarios may include:
- Missing authorization header.
- Expired token.
- Invalid signature.
- Token with the wrong audience.
- Token from an untrusted issuer.
- Token that is not yet valid.
- Valid token with insufficient permissions.
- Valid authorized request.
Expected HTTP responses depend on your application's authentication and authorization contract.
For many systems, authentication failures return 401 and authorization failures return 403, but you should verify your own API's documented behavior.
12. What about encrypted JWTs?
So far, we've discussed the common signed JWT format.
But JWT claims can also be represented using JSON Web Encryption (JWE).
A compact JWE typically contains five segments.
A basic decoder cannot simply read its encrypted payload without appropriate decryption material.
ToolZenHub's JWT Decoder recognizes the five-part format and refuses to present encrypted content as though it could decode it.
This is an important distinction.
Base64URL decoding and cryptographic decryption are not the same operation.
13. A useful JWT debugging checklist
When authentication fails, check:
- Is the access token present?
- Is it a JWT or an opaque token?
- Is its structure consistent with the expected token format?
- Does the expiration claim use seconds?
- Is the token within its claimed validity window?
- Does the issuer match your trusted identity provider?
- Is the audience correct for your API?
- Is the signature verified using an approved algorithm and trusted key?
- Does the token contain the required claims?
- Does the user have permission for the requested operation?
- Has the token or associated session been revoked?
- Are sensitive tokens excluded from logs and bug reports?
This helps separate formatting issues, time-related issues, trust failures, and authorization problems.
14. When should you decode and when should you verify?
Here's a useful distinction.
Decode a JWT when:
- You're inspecting a test token.
- You're debugging the contents of a claim.
- You're checking the structure of a token.
- You're reading an expiration timestamp.
- You're investigating whether an API client received the expected data.
Verify a JWT when:
- You're authenticating a request.
- You're making an authorization decision.
- You're establishing the issuer's authenticity.
- You're deciding whether token claims can be trusted.
- You're protecting application resources.
A decoder supports investigation.
A verifier helps establish whether the token can be trusted under a particular security policy.
Don't substitute one for the other.
Final thoughts
JWT debugging becomes much easier when you separate three different questions:
What does the token contain?
Decoding helps answer this.
Can the token be trusted?
Signature verification and claim validation help answer this.
Should the application allow this operation?
Authorization rules and application state determine this.
Mixing these questions can create serious security vulnerabilities.
For quick inspection of safe test tokens, you can use the browser-based JWT Decoder I maintain:
https://toolzenhub.in/tools/jwt-decoder/
It displays the token's structure, claims, and timestamps while explicitly warning that the signature is not verified.
The goal is to reduce repetitive debugging work without giving a false sense of security.
References
- RFC 7519 — JSON Web Token
- OWASP — JSON Web Token Security Testing
- OWASP — REST Security Cheat Sheet
- MDN — Session Management Security
Disclosure: I maintain ToolZenHub, the website referenced in this article. This article was prepared with AI assistance and requires technical and editorial review before publication.
Top comments (0)