1. Insight
Insight
The problem this article addresses and why it matters.
The alg: none problem is older than your JWT library
In 2015, security researcher Tim McLean published Critical vulnerabilities in JSON Web Token libraries detailing how several mainstream JWT libraries allowed an attacker to forge tokens by setting the header's alg field to none. Eleven years later, the JWT RFC 7519 §5.2 still lists none as a valid algorithm value — and at least two of the top npm JWT libraries still accept it as a default verification option unless you explicitly disable it.
The alg: none confusion is one of about a dozen production-grade JWT footguns that aren't theoretical. They land in penetration test reports. They cause real breaches. The OWASP JSON Web Token for Java cheat sheet calls out seven distinct hardening categories; the Auth0 JWT handbook covers more. Most teams have heard of these issues. Few teams audit their actual production tokens against them.
Why "we use a JWT library" isn't enough
Token forgery isn't the only attack surface. A token issued with a 24-hour expiry is fine — until a developer accidentally uses it for a CSRF-defence cookie that lives 30 days. A token without an aud claim is fine — until the same iss issues tokens for two services and one service replays them at the other. A signed token with HS256 is fine — until the verification code uses the public RSA key as the HMAC secret because someone copy-pasted from a tutorial.
These failures don't show up at code-review time. They show up when a security audit happens, or worse, when an attacker finds them first. The pattern is always the same: the team trusts their JWT library to be correct, and the library is correct — but the usage of the library is what determines security.
What this article delivers
The jwt_hardener tool decodes any JWT (signed or unsigned) and audits the header and payload against a checklist derived from RFC 7519, RFC 8725 (JWT BCP), and the OWASP recommendations. It produces a security score (0-100), a list of severity-ranked issues, and — in hardened mode — a concrete recommended replacement header + payload with a unified diff showing what to change. We'll cover the audit categories, the regression-comparison mode for CI pipelines, and the failure modes the tool cannot detect (because they're structurally invisible at the token level).
2. Intent
Intent
What you will be able to do after reading.
By the end of this article you will be able to:
- Decode any JWT (no signature key required) and surface its full security posture in one call
- Read the severity-ranked issue list and map each finding back to a specific RFC requirement or OWASP rule
- Generate a hardened replacement header + payload spec to guide code changes that fix the findings
- Wire the regression mode into CI so a JWT issuance change can't ship without comparing security scores before and after
- Know which failure modes are not surfaced (signing-key strength, RNG quality of the IV, side-channel timing in the verifier) and where to look for those
The Examples section walks through three real-world failures: the alg: none forgery, the missing-audience-claim cross-service replay, and the long-expiry session token.
3. Examples
Examples
Annotated code and worked scenarios.
Before / after: the alg: none token
A token issued by a misconfigured service with alg: none (no signature at all):
Before: the raw token (decode the first segment in your head — it's base64url of {"alg":"none","typ":"JWT"}):
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwiZW1haWwiOiJhQGEifQ.Note the trailing dot with no signature payload. Most JWT libraries reject this on verify, but some accept it on decode calls that a developer then mistakenly trusts for authorisation.
After running it through the hardener:
jwtHardener({
token: 'eyJhbGciOiJub25lIi...',
outputMode: 'audit',
});
// header: { alg: 'none', typ: 'JWT' }
// payload: { sub: '1', email: 'a@a' }
// score: 0
// issues: [
// {
// severity: 'critical',
// field: 'header.alg',
// message: 'alg=none allows trivial forgery. RFC 8725 §3.1 mandates rejection.',
// fix: 'Set alg to a real algorithm (ES256, EdDSA, or HS256+). Verify alg in the allowlist.',
// },
// {
// severity: 'high',
// field: 'payload.exp',
// message: 'No exp claim. Token is valid indefinitely.',
// fix: 'Add exp claim with a reasonable TTL (<= 1 hour for access tokens).',
// },
// {
// severity: 'high',
// field: 'payload.iat',
// message: 'No iat claim. Cannot determine token age.',
// fix: 'Add iat claim at issuance.',
// },
// {
// severity: 'medium',
// field: 'payload.aud',
// message: 'No aud claim. Token has no audience binding.',
// fix: 'Add aud claim naming the intended consumer service.',
// },
// ]Score 0 because the critical issue zeroes out everything else.
Before / after: hardened spec mode
Same input, but outputMode: 'hardened' returns a concrete recommended structure plus a unified diff:
jwtHardener({
token: 'eyJhbGciOiJub25lIi...',
outputMode: 'hardened',
});
// hardenedSpec: {
// recommendedHeader: { alg: 'ES256', typ: 'JWT', kid: '<rotate-keyed-id>' },
// recommendedPayload: {
// sub: '1',
// email: 'a@a',
// iss: '<your-service-name>',
// aud: '<consumer-service-name>',
// iat: 1747845000,
// exp: 1747848600,
// nbf: 1747845000,
// },
// diff: `
// - alg: none
// + alg: ES256
// + typ: JWT
// + kid: <rotate-keyed-id>
// - (no exp)
// + exp: 1747848600
// - (no iat)
// + iat: 1747845000
// - (no nbf)
// + nbf: 1747845000
// - (no iss)
// + iss: <your-service-name>
// - (no aud)
// + aud: <consumer-service-name>
// `
// }The agent or developer reads the diff, applies it to their issuance code, and re-runs the audit on a fresh token to confirm the score is now 100.
Before / after: regression-mode CI gate
jwtHardener({
token: 'eyJ...currentlyIssued...',
compareAgainst: 'eyJ...previouslyIssued...',
outputMode: 'audit',
});
// regression: {
// scoreChange: -15,
// newIssues: [
// { severity: 'medium', field: 'payload.exp', message: 'exp now 24h, was 1h' },
// ],
// fixedIssues: [],
// }Wire this into your CI: fetch a current token from the staging issuer, run it through the hardener with compareAgainst pointing at last week's snapshot. Fail the build on any scoreChange < 0. This catches the case where someone "fixes" a token by accident — bumping exp from 1h to 24h to "make testing easier" and shipping it to prod.
When humans use this
The most common use is during a security audit: paste a production JWT into the web UI and instantly see what the auditor will flag. The hardener's checklist mirrors the OWASP and RFC 8725 BCP recommendations, so the audit result becomes the team's pre-audit checklist. Engineers fixing JWT issuance bugs use the outputMode: 'hardened' diff as a code-review reference — the diff shows exactly which fields the new token must contain.
When agents use this
Two patterns dominate:
- Code-generation agent issuing JWTs. When an agent is asked to scaffold an auth flow, it writes JWT issuance code, then calls
jwt_hardeneron a sample token from that code, then injects the hardener's recommended header + payload back into the generated code. The hardened spec acts as a contract the agent reasons about deterministically — much more reliable than asking the LLM to "issue a secure JWT" without verification. - Continuous integration agent. A multi-step CI agent runs against every PR that touches auth code. One step generates a sample token from the new code, another step runs the hardener with
compareAgainstset to the main-branch baseline, and the agent posts a structured PR comment with the score delta and any new issues. This converts JWT-issuance security review from a manual auditor task to an automated gate.
Edge cases
Encrypted JWTs (JWE)
The hardener decodes signed tokens (JWS). Encrypted tokens (JWE — header enc field present) are not supported — the payload is encrypted with a key the tool doesn't have. JWE tokens return UNSUPPORTED_FORMAT with the suggestion to decrypt the token first if you need a payload audit.
Tokens with non-standard claims
Custom claims (custom:role, app:tenant_id) pass through without warning. The hardener doesn't enforce claim naming conventions because there's no universal standard. If you want naming enforcement, layer a separate schema validation step on the decoded payload.
Symmetric vs asymmetric algorithm flag
The hardener flags HS256 with a medium-severity warning when an iss claim is present (cross-service signing should prefer asymmetric algorithms so the consumer doesn't need the signing key). It does not have visibility into how the signing key is stored, rotated, or distributed — those are operational concerns outside the token itself.
Clock-skew tolerance
The tool flags exp - iat durations longer than 1 hour as a medium-severity finding (per OWASP guidance for access tokens). It does not check nbf against the current wall clock — that's a verifier-time check, not a token-issuance check. The hardener audits the token, not the running system.
4. Documentation
Documentation
Reference signatures, edge cases, and lookup tables.
Input parameters
Field | Type | Required | Default | Description |
|---|---|---|---|---|
|
| ✓ | — | The JWT to audit. Signature is decoded but not verified — no signing key required |
|
| ✗ | — | A second JWT to diff against. When provided, output includes |
|
| ✗ |
|
|
Output shape
{
header: object; // decoded header
payload: object; // decoded payload
score: number; // 0-100, calibrated to OWASP + RFC 8725 BCP
issues: Array<{
severity: 'critical' | 'high' | 'medium' | 'info';
field: string; // 'header.alg', 'payload.exp', etc.
message: string;
fix: string;
}>;
hardenedSpec?: { // present when outputMode is 'hardened' or 'both'
recommendedHeader: object;
recommendedPayload: object;
diff: string; // unified diff format
};
regression?: { // present when compareAgainst provided
scoreChange: number; // newScore - oldScore
newIssues: Array<{ severity, field, message, fix }>;
fixedIssues: Array<{ severity, field, message }>;
};
}Severity scale
Level | Examples | Score impact |
|---|---|---|
|
| −60 to −100 |
| No | −15 to −30 |
| No | −5 to −15 |
| No | 0 |
Error codes
Code | When it fires | Recovery |
|---|---|---|
|
| Provide a non-empty JWT |
| Not a valid JWT structure (not three base64url segments separated by dots) | Verify the input is actually a JWT |
| Token is JWE (encrypted) rather than JWS (signed) | Decrypt first; the tool audits payload structure only |
| Prompt-injection content detected in claims | Sanitise the source that produced the token |
When NOT to use this tool
jwt_hardener does not verify signatures. It audits the structure of a token, assuming an attacker who can read the token but not forge a new one. If you're investigating whether a specific token was issued by a specific key, use a signature verifier (the JWT library you already use) — not this tool.
The tool also doesn't audit the surrounding auth flow: how the token is transported (Bearer header vs cookie), how it's stored on the client (localStorage vs httpOnly cookie), whether it's revoked on logout. Those are session-management concerns that live above the token. The hardener stops at the JWT boundary.
For production token forgery testing, use a dedicated JWT pen-test tool (jwt_tool, Burp's JWT extension) that can try algorithm confusion, key confusion, and brute-force on weak HMAC secrets.
Performance notes
Audit-mode execution: under 5ms typical. Hardened-mode adds 1-2ms for the diff generation. The tool is deterministic — same input always produces the same output — so REST responses are Edge-Cache eligible. The audit ruleset is versioned; finding text and severity may shift between releases as new RFCs and OWASP guidance lands. Pin the tool to a specific version in CI workflows if your build pipelines need exact reproducibility.