Why I started paying attention to JWTs
The first time I saw a JWT, I thought it was just a long string of random characters. It looked opaque, and I did not want to spend time decoding it. That changed when I found a token that still worked after I removed the signature. The application accepted it as if nothing had happened. Since then, I have made it a habit to decode every token I find and to understand what the application trusts.
This post is my personal study note on JWT security. It covers the structure of the token, the bypasses that I see most often, and the fixes that I would recommend to a development team. The focus is practical. I want to know what to test and how to explain the result in a way that makes sense to someone who is responsible for the code.
The three parts of a token
A JWT is made of three parts separated by dots. The first part is the header, the second is the payload, and the third is the signature. Each part is base64url-encoded, which means I can decode it without any special tools. When I decode the header, I usually see an algorithm name and a token type. When I decode the payload, I see the claims, such as the user ID, the expiration time, and the issuer.
The signature is the security control. It binds the header and payload to a secret or a private key. If the application verifies the signature correctly, an attacker cannot modify the token without invalidating it. If the verification is missing or flawed, the attacker can change the claims and become any user.
The important thing to remember is that the header is not trusted input from the server's point of view. The server should have its own configuration that says which algorithm to use. If the server reads the algorithm from the header and uses it directly, an attacker can change the algorithm and potentially forge a token.
The first thing I do: decode the token
Before I test anything, I decode the token. I copy the header and payload into a decoder and read the claims. I look for the algorithm, the user identifier, the role, the expiration time, and any custom fields. Custom fields are interesting because applications sometimes use them for authorization decisions. If the payload contains a field like isAdmin or role, I want to know whether the server trusts that field.
I also look at how the token is delivered. It might be in an Authorization header, a cookie, a query string, or a request body. The delivery method affects the attack surface. A token in a cookie can be protected by cookie flags, while a token in a query string can leak through logs and referrer headers. A token in a header is more likely to be read by the backend without additional parsing.
The expiration claim is another thing I check. If the token has no expiration or a very long expiration, the risk of theft is higher. I test whether the server actually checks the expiration by modifying the timestamp and resending the token. If the server accepts an expired token, that is a separate finding.
The alg=none problem
The easiest JWT bypass to test is the alg=none family. Some libraries accept a token whose header says alg=none and whose signature segment is empty. The server skips signature verification because the header tells it to. I test this by decoding the token, changing the algorithm to none, removing the signature, and re-encoding the token.
The test is quick, but the result is not always a clear yes or no. Some libraries reject alg=none by default, while others reject it only when the library version is updated. Some libraries accept a case variation like None or NONE. Some accept an empty signature even when the header still says HS256. I test a few variations and record which ones change the server behavior.
If the server accepts an unsigned token, the finding is severe. An attacker can set any claims, including the user ID and role, and authenticate as any account. The fix is to configure the library with an explicit allowlist of algorithms and to reject tokens that are missing a signature.
Algorithm confusion between RS256 and HS256
The next bypass is algorithm confusion. The most common version involves RS256 and HS256. In RS256, the token is signed with a private key and verified with a public key. In HS256, the token is signed and verified with the same secret. The confusion happens when the server expects RS256 but the attacker changes the header to HS256. The attacker then signs the token with the public key, which is often available to anyone, and the server verifies it using that public key as the HMAC secret.
The test has a few steps. First, I obtain the public key. The key can be found in a JWKS endpoint, a certificate, a public GitHub repository, or a comment in the source code. Second, I change the token header to HS256. Third, I sign the token with the public key as the secret. Fourth, I submit the token and see whether the server accepts it.
If the server accepts the forged token, the finding is serious. The attacker does not need the private key. The fix is to make the verification algorithm explicit and to prevent the same key material from being used for both asymmetric and symmetric algorithms. Libraries often have a setting for this, and the development team should enable it.
Weak HMAC keys
If the token uses HS256 and I do not know the secret, I can try to brute force it offline. The signature is a value that depends on the secret, so I can take a captured token and test candidate secrets without sending anything to the server. A short, common, or predictable secret can be recovered quickly.
I keep a wordlist of common passwords, product names, company names, and standard test values. I run the wordlist against the captured token. If the secret is in the list, I find it in seconds. A recovered secret lets me forge tokens for any user and any expiration time.
The fix is to use a long random secret and to rotate it regularly. Secrets should not be stored in source code, configuration files, or environment variables that are shared with everyone. A key management service can make rotation easier and reduce the chance that the secret is leaked.
Header parameters like kid and jku
The kid header is used by some servers to select which key to use. The value is supposed to be an identifier that the server maps to a key. If the server does not validate the identifier, an attacker may be able to point the server to an attacker-controlled key.
The most common test is path traversal. If the kid value is used as a file path, a value like ../../../../etc/passwd might make the server read an arbitrary file and use its contents as the key. Another test is to use a URL as the kid value. If the server fetches the URL, the attacker can host a key and control the signature.
Other headers have similar risks. The jku header can point to a JSON Web Key Set, the x5u header can point to a certificate, and the jwk header can embed a key directly in the token. I test each header with values that I control and observe whether the server changes its verification behavior. If the server fetches an external resource, the behavior is visible in the server response or in an out-of-band callback.
Claims that the server should validate
The payload contains claims that the application should check. The exp claim is the expiration time. The nbf claim is the not-before time. The iss claim identifies the issuer, and the aud claim identifies the audience. If the server ignores these claims, an attacker can use a token that was issued for another application or that should have expired.
I test the claims by modifying them and resending the token. If I change the issuer and the token still works, the server is not validating the issuer. If I change the audience and the token still works, the server is not validating the audience. These tests are easy to run and often reveal that the server only checks the signature.
The role and user claims are also worth testing. If the server uses a role claim from the payload to make authorization decisions, I can change the claim to admin and test whether the server grants me admin access. This is a common issue in applications that treat the JWT payload as trusted data.
How I test a token
My testing process is straightforward. I capture a valid token and decode it. I test alg=none with several variations. I test algorithm confusion if the public key is available. I run a wordlist against the HMAC secret. I modify the header parameters and claims. I check the expiration and issuer validation. I record every test and the server response.
I do not modify the token in the live application in a way that could cause damage. I test with my own account and my own data. If I need to verify that a forged token grants access to another account, I use an account that I control. The goal is to demonstrate the flaw without harming real users.
What the fix looks like
The most important fix is an algorithm allowlist. The server should specify which algorithms it accepts and should never trust the algorithm from the header. The second fix is to validate all standard claims, including issuer, audience, expiration, and not-before. The third fix is to use strong keys and rotate them. The fourth fix is to reject tokens with unexpected header parameters.
Logging is also important. When signature verification fails, the server should log the reason and the source of the request. A high number of failed verifications can indicate an active attack. The application should also have a clear error response that does not reveal the reason for the failure, because the reason can help an attacker refine the attack.
Mistakes I make
My biggest mistake is assuming that a token with a valid-looking signature is secure. The signature only matters if the server verifies it. My second mistake is testing only one alg variation. The server may reject none but accept None, or it may accept an empty signature in a different format. My third mistake is ignoring the claims validation. Even a correctly signed token can be dangerous if the server trusts the payload.
The fourth mistake is using a token from the wrong application. Tokens from other applications may be accepted if the issuer and audience are not validated, but testing with the wrong token can produce confusing results. I always use a token that I captured from the application under test.
My quick checklist
- Decode the token and record the header, payload, and signature.
- Test
alg=nonewith case and format variations. - Test RS256 to HS256 confusion if the public key is available.
- Brute force weak HMAC secrets with a wordlist.
- Test
kid,jku,x5u, andjwkheader parameters. - Modify
exp,nbf,iss,aud,role, and user claims. - Record the server response for every modified token.
- Verify whether the fix includes an algorithm allowlist and claim validation.
What I would do next time
Next time I want to check the library version before I start testing. The behavior of a JWT library often depends on its version, and knowing the version saves me from testing variations that cannot work. I also want to spend more time on claim validation, because that is where I find the issues that the signature checks do not catch. JWT testing is not about decoding a string. It is about understanding what the application actually trusts.