← Security research

How to Tell If a JWT Is Actually Secure

Most developers meet JSON Web Tokens the same way. A library hands you a token, you store it, you send it back on every request, and the framework tells you whether the user is logged in. It works, so nobody looks inside.

The three parts of a JSON Web Token with the header highlighted

I look inside for a living, and tokens are one of the places where a product that seems well built turns out to have an authentication model held together by assumptions. The flaws are rarely exotic. They are usually one missing check, in one place, that nobody thought to write because the library appeared to be handling it.

This is a walkthrough of how I actually examine a token during a security review. You can follow it with any token you have access to. Nothing here requires a proxy, a scanner or a lab environment, just a token and a willingness to read it properly.

What a JWT actually is

A JWT is three base64url encoded strings joined by dots. Header, payload, signature.

The header describes how the token was signed. The payload carries the claims, which is the data the application trusts about whoever is holding the token. The signature is what proves the first two parts were issued by the server and have not been altered since.

That is the whole structure. There is no encryption anywhere in it.

This is the first thing worth internalising, because the language around tokens misleads people constantly. A token is encoded, not encrypted. Anyone holding it can read every claim inside it without any key at all. If you want to see that for yourself, paste one into my JWT decoder. It renders the header and payload as readable JSON in the browser, and nothing you paste leaves your machine.

That matters more than it sounds. I regularly find tokens carrying internal user identifiers, role names, tenant identifiers, email addresses, feature flags and occasionally things that were never meant to be visible to the account holder at all. None of that is protected by the signature. The signature stops the holder changing the values. It does not stop them reading the values.

So the first question on any token is simply: what is in here that I would not want this user to see?

Decoding is not verifying

Here is the distinction that causes more real vulnerabilities than any other single idea in this subject.

Decoding a token means splitting it on the dots and base64 decoding the parts. It tells you what the token says. It involves no cryptography and proves nothing.

Verifying a token means recomputing the signature with the correct key and the correct algorithm, confirming it matches, and then checking that the claims inside are acceptable for the request being made.

Plenty of libraries expose both operations with similarly named functions, and the decode one is usually easier to call because it needs no key. I have found production code that decoded a token to read the user identifier out of it and then used that identifier to load the account, without ever verifying the signature. The application behaved perfectly for every honest user. It also allowed anyone to forge a token for any account by editing the payload and re encoding it, because nothing ever checked whether the signature was real.

When I review code, that is one of the first patterns I search for. In a source code review it stands out quickly because the two calls look different once you know what you are looking at. From the outside, during testing, you find it by changing a claim, discarding the signature or leaving it intact, and watching whether the application notices.

Start with the header, not the payload

Most people decode a token and go straight to the payload, because that is where the interesting data is. I start with the header, because that is where the dangerous data is.

The header usually contains two fields. The alg field names the signing algorithm. The kid field, when present, names which key was used, so the server can pick the right one from a set.

The reason the header matters is that it is attacker controlled. It arrives with the token, and the token arrives from the client. If the server reads the header to decide how to verify the token, then the client is partly deciding how its own token gets verified. That is a trust boundary violation sitting in plain sight, and almost every serious JWT flaw is a variation on it.

The none algorithm

The specification permits an algorithm value of none, meaning an unsecured token with no signature at all. It exists for cases where the token’s integrity is already guaranteed by something else.

The attack writes itself. Change alg to none, change whatever claims you want, drop the signature, send it. If the server honours the header’s instruction, it skips verification entirely and accepts the forged token.

Most current libraries refuse this by default now. The flaw survives in two situations. The first is applications pinned to old library versions, which is more common than people expect in codebases that have been running for years. The second is applications that handle multiple algorithms and build their own verification logic around the library, where the developer reintroduced the problem while trying to be flexible.

When testing, I try several casings. Some implementations compare the string against a blocklist rather than an allowlist, and a value like None or NONE slips past a check that was only looking for the lowercase form.

Algorithm confusion

This one is more interesting, because every individual piece of the system is behaving correctly.

Asymmetric algorithms like RS256 sign with a private key and verify with a public key. The public key is public by design. It is often published at a well known endpoint so that other services can verify tokens.

Symmetric algorithms like HS256 use one shared secret for both signing and verification.

Now consider a server that reads alg from the header to decide which verification path to take, and holds the RSA public key for the RS256 path. An attacker changes alg to HS256, signs the modified token using the public key as if it were the HMAC secret, and sends it. The server reads HS256 from the header, takes its key material, and uses it as a shared secret. The signature validates, because the attacker used exactly the same value.

Nothing was cracked. The attacker simply persuaded the server to use a public value as a private one, by telling it which algorithm to use.

The defence is to fix the expected algorithm on the server side and reject anything else, rather than trusting the token to say how it should be checked. When I test this, the first thing I look for is whether the public key is retrievable, usually from a JWKS endpoint, and whether the application accepts a token whose algorithm differs from the one it issues.

Key identifier injection

The kid field tells the server which key to use. If the server takes that value and uses it to look something up, the question becomes what kind of lookup.

If it indexes into a fixed set of keys, that is fine. If it is used to build a file path, a path traversal value may point the server at a file the attacker can predict the contents of, and a token can then be signed to match. If it goes into a database query, it is an injection point like any other, in a place nobody thinks to check because it sits in authentication rather than in application logic.

The pattern underneath is the same as the previous two. A value controlled by the client is steering how the server verifies that client’s own credentials.

Then read the claims

Once the header is understood, the payload is worth going through claim by claim rather than skimming.

exp is the expiry. Its absence is a finding on its own, because a token without an expiry is valid until the signing key changes, which in many products is never. Its presence is only half the story. The check that matters is whether the server actually enforces it. I have seen tokens with a perfectly correct exp claim accepted months after that time had passed, because the verification call was made with expiry checking disabled, usually as a debugging convenience that was never removed.

iat and nbf give the issue time and the not before time. They are worth reading mainly because they tell you the intended lifetime of the token, which tells you how long a stolen one stays useful.

iss and aud name the issuer and the intended audience. These matter enormously in products with more than one service or more than one environment. If the audience is not validated, a token issued by the same provider for a different application may be accepted by this one. If several services share a signing key and none of them check the audience, a low privilege token from one service becomes a valid token for another. That is a straightforward privilege escalation path, and it is invisible unless you look at the claim and then test whether anything enforces it.

sub is the subject, the identity the token represents. The thing to establish is whether the application uses this value as the authoritative identity, or whether it also accepts a user identifier from the request body or a parameter. Applications that do both have a gap, because the two can disagree, and the one that wins is rarely documented.

Then there are the custom claims, which is where products put roles, permissions, tenant identifiers and plan tiers. These deserve the most attention, because they are the application’s own authorisation data carried inside a credential the user holds. Two questions decide whether they are a problem. Does the server re check these values against its own records, or does it trust what the token says? And does the token still carry the old values after the user’s role or tenancy changes?

That second question leads somewhere people underestimate.

The revocation problem

A verified JWT is valid because the maths works, not because the server looked anything up. That is the entire appeal. It is also the entire problem.

When you remove a user from an organisation, downgrade their role or disable their account, their existing token does not know. It stays valid until it expires. If the role is baked into the claims and the server trusts the claims, that user keeps the access the token describes, for the remaining lifetime of the token, with nothing in the admin interface indicating it.

For a fifteen minute token that is a narrow window. For a token with a thirty day expiry, or no expiry at all, it is a serious finding, and it is one I have reported more than once. It usually turns up while testing multi account and team management flows rather than while testing authentication, which is why it survives reviews that look at login in isolation.

This is a design consequence rather than a bug, which is why it needs a deliberate answer: short lived access tokens with refresh handled server side, or a revocation list checked on sensitive operations, or re reading the user’s current role from the database rather than from the token. What is not acceptable is a thirty day token carrying a role claim that nothing ever rechecks.

Weak secrets

When a token is signed with HS256, the security of the whole scheme reduces to the strength of one shared secret. If that secret is guessable, an attacker can mint valid tokens for any user with any claims they like.

Development defaults are the usual culprit. Secrets like secret, the framework’s own example value, or the project name, get set early for convenience and then quietly ship. Cracking tools try these in seconds against a captured token, offline, with no requests to the application at all, which means there is nothing to rate limit and nothing to detect.

The remediation is uninteresting and absolute. A long random secret from a proper source, stored outside the codebase, rotated when anyone who had access to it leaves.

Where the token lives

Everything above assumes an attacker who has a token. How they get one is a separate question, and it is where JWTs meet the rest of the application’s security posture.

A token in localStorage is readable by any JavaScript running on the page, which means a single cross site scripting flaw anywhere becomes full account takeover. A token in a cookie marked HttpOnly and Secure with a sensible SameSite value is out of reach of page scripts, at the cost of needing CSRF protection on state changing requests.

Tokens also end up in places nobody intended. Query strings, which land in server logs, browser history and referrer headers. Error reports. Analytics payloads. Third party scripts with access to the page. I check for all of these, because a perfectly implemented token that leaks through a logging pipeline is no better than a badly implemented one.

Transport headers are part of the same picture. If you want to see how a site is configured on that front, my HTTP security header analyzer reports what a page is actually sending.

A short checklist

If you only take one thing away, make it this list, and work through it against your own implementation.

Confirm the server fixes the expected algorithm rather than reading it from the token. Confirm none is rejected in every casing. Confirm the signature is verified on every authenticated path, not just at login. Confirm expiry is both present and enforced. Confirm the issuer and audience are validated if you run more than one service or environment. Confirm the signing secret is long, random and not in the repository. Confirm roles and tenancy are re checked server side rather than trusted from claims. Confirm you have an answer for what happens to an existing token when a user’s access is revoked. Confirm the token is stored somewhere a page script cannot reach it. Confirm it never appears in a URL.

Most products I review pass most of these. The finding is almost always one item, in one code path, and the impact is usually larger than the size of the mistake suggests, because authentication sits underneath everything else.

On the tools

The JWT decoder and the HTTP security header analyzer are the first two tools I have published, and both run entirely in your browser. Nothing you paste into either one is transmitted anywhere. For a credential, that is not a detail, it is the whole reason I built them rather than pointing people at an existing site.

There are three more in development: an asset discovery tracker, an API review workspace and a finding workflow. They are listed on the tools page so you can see what is coming, not because they are usable yet.

If the checklist above raised something you want looked at properly, that is what API security testing and a full penetration test are for. Token handling is one of the first places I go in both.

Frequently asked questions

Is a JWT encrypted?

No. A standard JWT is encoded, not encrypted. Anyone holding the token can read the header and payload with no key. The signature prevents modification, not reading. A separate standard, JWE, does provide encryption, but it is used far less often and is not what most applications mean when they say JWT.

Can someone change the data inside a JWT?

Only if the server fails to verify the signature correctly. Changing a claim invalidates the signature, so a correctly implemented server rejects the token. The flaws in this article are all ways a server can be persuaded to skip or subvert that check.

What is the alg none vulnerability?

It is an attack where the token’s header is changed to declare that no signing algorithm was used. If the server trusts that declaration, it accepts the token without verifying any signature, which allows an attacker to forge arbitrary claims. Modern libraries reject it by default, but it survives in older versions and in custom verification code.

How long should a JWT last?

Short enough that a stolen token stops being useful quickly, and short enough to limit how long revoked access persists. Access tokens measured in minutes, with refresh handled server side, are a common and reasonable pattern. Tokens lasting weeks or months carrying role information are where I find real problems.

Is it safe to store a JWT in localStorage?

It is readable by any JavaScript running on the page, so a single cross site scripting flaw becomes account takeover. A cookie marked HttpOnly and Secure keeps the token out of reach of page scripts, at the cost of requiring CSRF protection. Which is right depends on your architecture, but localStorage raises the impact of every XSS flaw you have.

SECURITY REVIEW

Need help testing your SaaS application?

I help SaaS and API teams find exploitable vulnerabilities, access control gaps and attack paths before they become incidents.

SaaS SecurityAPI SecurityAccess ControlManual Validation
Request a Security Review