Summary
alg=none x Attacker-Controlled Object Check
- Introduction Almost every portal I test authenticates with a JWT, and the flow is always the same. You log in, the server signs a token stating who you are and what you can see, and every request after that carries the token instead of a session lookup. The server verifies the signature, trusts the claims, and serves the request. The whole model rests on one step. If the signature check is real, the claims are the server’s own words handed back to it. If the check is missing, the claims are just something the client typed. During a recent engagement I found a partner portal where that check was not there. I wrote a token by hand saying I was a valid user, sent it with an empty signature, and the API let me in. That on its own is an authentication bypass. Worse was the design sitting behind it. The endpoint decided which records I could read by reading a list of ids out of the token. Which I also wrote. One forged token per id, and the partner directory read out one record at a time, with no account and no session anywhere.
- The bug What the token is doing Every request to the directory carries a Bearer JWT. Before the route runs, a validation layer parses that token and pulls a user object out of the payload. That object is the entire authorization context. It carries the caller’s id, a rights array, and the list of partners this caller may see: {“sub”:“11”, “user”:{“id”:“11”, “rights”:[“directory.READ”], “partners”:{“partner”:[ {“key”:“portal”,“id”:“11”}]}}, “iss”:“portal”, “iat”:1783183500,“exp”:1793183499 Nothing is wrong with that on its own. It is ordinary stateless authorization: the token gets to be the source of truth because the server signed it, and reading permissions from it saves a database round trip per request. All of it depends on the signature actually being checked. The signature is not checked I started where I always start, with the plain request and no token at all: GET /api/partner?id=11 HTTP 500 {“Error”:“Error during validation: ”} The server wants a token before it will reach the lookup, and it is willing to describe its own validation failures, which is become starting point because from here on every token I malform comes back with a hint about the why. So I built a token by hand. A JWT is three base64url segments joined by dots: header, payload, signature. The header names the algorithm that signed the token, and the spec allows that name to be the literal string none, meaning the token is unsigned. That is a legal construction, meant for tokens whose integrity is guaranteed some other way, and every serious library rejects it by default. Here is the complete forged token. The header specifies that no signing algorithm is used, while the payload contains the user object shown above and identifies user ID 11 as the authenticated caller. One of the most common JWT forgery techniques is called none algorithm attack, in which the signature is an empty string : import base64, json def seg(obj): raw = json.dumps(obj, separators=(”,”, ”:”)) b = base64.urlsafe_b64encode(raw.encode()) return b.rstrip(b”=“).decode() header = {“alg”: “none”, “typ”: “JWT”} payload = {“sub”: “11”, “user”: {…}, “iss”: “portal”} token = seg(header) + ”.” + seg(payload) + ”.” Two details in there are worth knowing, because getting either wrong makes the token bounce before anything reads the algorithm. base64url is not plain base64, so the trailing = padding has to come off. And the token ends on a dot with nothing after it, because a JWT is three segments and the third one is empty here. Drop that final dot and most parsers call the token malformed and never look at alg at all. Then I sent it: GET /api/partner?id=11 Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0 .eyJzdWIiOiIxMSIsInVzZXIi… HTTP 200 {“partner”:{“id”:“11”,“number”:“10000030”, “type”:“sub”,“billing”:“direct”, “contact”:{“city”:”…”,“fax”:null}}} The record came back. I had no account on this portal, sent no cookie, held no session, and never touched a signing key. Before calling this a final finding, I wanted to rule out the possibility that the server was failing for an unrelated reason and returning data accidentally. To verify the behavior, I sent five different tokens to the same endpoint, including tokens crafted using several other common JWT forgery techniques. no token → 500 validation error malformed token → rejected RS256, real signature → rejected HS256, tampered signature → 500 no record alg=none, empty signature → 200 full record As a result, the validator consistently rejected tokens with invalid signatures and other forgery techniques, while accepting only the token crafted using the none algorithm attack. This confirms that the behavior was specific to improper handling of unsigned JWTs, rather than a broader validation failure or an accidental server response. The authorization check trusts the same forged token Reading my own record proves the bypass and not much else. But it does not yet prove that I can access records belonging to other partners. The remaining question was whether the forged token could carry me beyond the partner ID embedded within it. The endpoint performs exactly one authorization check: it compares the id supplied in the query string with the partner ID contained in the presented token. token names 11, query asks id 11 → 200 record token names 11, query asks id 99 → 500 denied token names 99, query asks id 99 → 200 record The middle request confirms that the comparison is genuinely enforced. However, both values being compared are fully controlled by the requester: one comes from the query string, while the other comes from a forged unsigned token. The authorization check therefore exists, but it has no independent or trusted identity source against which to validate either value. Enumerating the partner directory consequently becomes a simple loop. Each request uses the same unsigned-token construction described above, with only the partner ID changed, while the query requests the corresponding ID: for pid in range(1, 200): tok = none_token(pid) # seg(header) + ”.” + seg(p) + ”.” r = requests.get(f”https://host/api/partner?id={pid}”, headers={“Authorization”: f”Bearer {tok}”}) Multiple IDs within the sampled range returned valid partner records. No legitimate login, existing session, additional verification, blocking mechanism, or effective rate limit was encountered. The disclosed records contained account numbers, partner types, billing methods, postal addresses, telephone and fax numbers, and, in several cases, company email addresses. Get Alvin Ferdiansyah’s stories in your inbox Join Medium for free to get updates from this writer. I stopped after sampling the specified range because the vulnerability and its impact had already been sufficiently demonstrated, and the exposed records belonged to real businesses. The finding is therefore confirmed as valid, sufficiently demonstrated, and ready to be filed as a report.
- Other forge techniques worth trying The alg=none attack is one of the oldest techniques in this family, and it still works surprisingly often. However, it is only one of several ways to attack the point at which a server decides how and with which key to verify a token. Most of these checks are inexpensive to perform, so they are worth including in a standard JWT testing workflow. Algorithm case and type variations Some validators attempt to block unsigned tokens by rejecting only the exact lowercase string none . Variations in capitalization, whitespace, or data type may bypass weak comparisons: {“alg”:“None”} {“alg”:“NONE”} {“alg”:“nOnE”} {“alg”:” none”} {“alg”:“none ”} {“alg”:[“none”]} Signature stripping Retain the original algorithm but remove the signature. Test both token shapes because parsers may interpret a missing third segment differently from an explicitly empty one: eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMSJ9. eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMSJ9 RS256-to-HS256 algorithm confusion RS256 verifies signatures using a public key, and that public key is often publicly available. If the validator trusts the algorithm declared in the JWT header, an attacker may be able to change the algorithm to HS256 and use the published RSA public key as the HMAC secret: curl -s https://host/.well-known/jwks.json > jwks.json After reconstructing the public key from the JWK parameters:
rebuild the PEM from n and e, then:
jwt.encode(payload, open(“pub.pem”,“rb”).read(),
algorithm=“HS256”)
The server compares the resulting key bytes, so serialization can determine whether the attack succeeds. Test common representations, including SPKI and PKCS#1 PEM, PEM with and without a trailing newline, DER, and the raw certificate contained in x5c
.
Weak HS256 secrets
When a token already uses HS256, its security depends entirely on the strength of the shared secret. Begin with obvious values, application names, and documented framework defaults. The token can then be tested against a controlled wordlist:
hashcat -a 0 -m 16500 token.txt rockyou.txt
A recovered secret allows an attacker to generate valid tokens with arbitrary claims.
jkw header injection
A JWT header may contain an embedded JSON Web Key. If the validator trusts that key without checking it against an approved key set, an attacker can provide their own public key and sign the token using the corresponding private key:
{“alg”:“RS256”,“typ”:“JWT”,
“jwk”:{“kty”:“RSA”,“e”:“AQAB”,“n”:"
| jq -r ‘.keys[].n’
curl -s https://host/.well-known/jwks.json
| jq -r ‘.keys[].n’
Matching moduli indicate that the environments use the same RSA key pair. If an attacker can obtain a legitimately signed token or gain access to the private key in a lower-trust environment, that token may also be accepted in production.
The same comparison can reveal vendor-default keys. If an instance publishes the same modulus as a key pair distributed in a public repository or installation package, its corresponding private key may already be publicly available.
4. Defense
Do not trust or use any JWT claim until the token’s signature and required claims have been successfully validated. Configure the verifier with an explicit allowlist of expected algorithms and trusted keys rather than allowing the token header to determine the verification method. Unsigned tokens and the none
algorithm should be rejected unconditionally.
In Java’s JJWT library, for example, the token should be parsed using a configured verification key. Unsigned tokens, invalid signatures, unexpected algorithms, and malformed tokens should result in an exception rather than authenticated claims being returned to the application.
Key-selection headers such as kid
, jwk
, jku
, x5u
, and x5c
must never be trusted without strict validation. Key identifiers should map only to predefined server-side keys, remote key locations should be explicitly allowlisted, and arbitrary filesystem paths or attacker-controlled URLs must not be accepted.
Authorization decisions must also be based on an identity independently established by the server, not solely on values carried in the request. In this case, a correct signature check would have prevented the attack immediately. However, even after that issue is repaired, the object-level authorization check remains important: the requested partner record should be validated against the authenticated account and its server-side relationships, rather than by comparing two values supplied by the caller.
5. Closing Notes
The interesting part was not that alg=none
still worked. It was how little effort it took to turn one accepted unsigned token into arbitrary identity impersonation and access to real partner records. The application had an authorization check, but that check relied on identity data taken from the same token the attacker controlled.
JWTs can look structurally correct while providing no meaningful security at all. A token should not be trusted because it decodes cleanly, contains familiar claims, or passes an object comparison downstream. Its signature, algorithm, key source, issuer, audience, and authorization context all need to be independently validated.
So when a JWT appears, do not stop after decoding it. Test the assumptions behind it. Sometimes the most damaging bypass is still hiding behind a single empty signature.