JWT CTF Lab: Build, Decode, and Verify Tokens
JWTs are not “magic auth.” A JWT is just a string. The only time it becomes useful is when you verify it correctly and treat its claims like untrusted input until that verification succeeds. Most JWT confusion comes from mixing up three different actions that sound similar but are not the same thing: creating a token, decoding a token, and verifying a token.
This post is a lab. You will generate real tokens, inspect them in multiple ways, verify them, and then deliberately break them so you can see exactly what failure looks like. The goal is not to memorize vocabulary. The goal is to understand where trust is created, where it is not, and what lines of code actually matter.
The mental model
A JWT has three parts separated by dots:
header.payload.signature
Each part is base64url-encoded. That encoding is not encryption. It’s just a compact way to transport bytes as text.
The header usually says what algorithm was used to sign (example: HS256) and the token type (JWT). The payload is a JSON object (claims). The signature is the proof that the header and payload were not modified after the token was signed.
If you only decode a token, you can see the header and payload. That is useful for debugging. It is not security.
If you verify a token, the library recomputes the signature using the secret/key you provide and checks that it matches the signature in the token. That is the moment the token becomes trustworthy.
The promise
By the end of this lab, you will have done all of these things on purpose and seen the output in your terminal:
- Generate a JWT
- Split it into its 3 dot-separated segments
- Manually base64url-decode the header and payload without using any library
- Decode using a library to prove it matches your manual decode
- Verify a correct token
- See verification fail when you change a single character in the payload
- See verification fail when you use the wrong secret
- See verification fail when the token is expired
If you don’t see those states happen, you didn’t actually learn JWT behavior yet. You only read about it.
What you need before starting
You need Node 18+ and a terminal. No frameworks. No web server. No database. This is intentionally small so you can see what is happening.
Start to finish
Step 1: Create a clean lab project
Create a folder, initialize Node, and install one dependency.
mkdir jwt-labcd jwt-labnpm init -ynpm install jsonwebtoken
Create a file:
touch index.js
Step 2: Add only the signer first
Instead of pasting a giant file all at once, we’ll build this lab in layers so every moving part is visible.
Open index.js and start with only this:
const jwt = require("jsonwebtoken");const SECRET = "demo-secret";const payload = {userId: "123",role: "user"};const token = jwt.sign(payload, SECRET, {algorithm: "HS256",expiresIn: "15s"});console.log("token:\n", token);
What each line does:
require("jsonwebtoken") loads the library that knows how to sign and verify tokens. Nothing here is JWT-specific yet. It’s just a Node module import.
SECRET is the shared key. With HS256, the same secret signs and verifies. Anyone with this value can mint valid tokens. That’s why real projects never hardcode it.
payload is just a plain JavaScript object. These fields are not trusted yet. They are just data you intend to sign.
jwt.sign(...) does three things internally: it builds a header, serializes the payload, and creates a signature using HMAC SHA-256 over both. The output is the compact dot-separated string.
Run it now:
node index.js
If you see a long string with two dots in it, signing worked. Nothing else in this lab matters until this step prints a token.
Step 3: Split the token into its three parts
Right now the token is just one long string. JWTs are easier to understand when you physically separate them.
Append this under your existing code:
const parts = token.split(".");console.log("\nheaderB64 :", parts[0]);console.log("payloadB64:", parts[1]);console.log("signatureB64:", parts[2]);
Explanation:
token.split(".") turns header.payload.signature into an array of three strings. Each string is base64url text. No encryption. No secrecy. Just encoding.
If parts.length is not 3, the token is malformed. A valid JWT always has exactly three segments.
Run again:
node index.js
You should now see three separate base64url segments printed. This is the raw shape of a JWT.
Step 4: Manually decode header and payload
Libraries make things feel magical. We’re going to remove the magic and decode by hand so you see exactly what’s inside.
Append this next:
function base64UrlToString(input) {const base64 = input.replace(/-/g, "+").replace(/_/g, "/");const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");return Buffer.from(padded, "base64").toString("utf8");}const headerText = base64UrlToString(parts[0]);const payloadText = base64UrlToString(parts[1]);console.log("\nheader (decoded text):\n", headerText);console.log("payload (decoded text):\n", payloadText);
Explanation:
JWT uses base64url, which is slightly different from normal base64. This helper function converts base64url into normal base64 and then into readable text.
After decoding:
- The header becomes a JSON string describing algorithm and type.
- The payload becomes your original object plus timestamps the library added.
Run again:
node index.js
You should literally see JSON printed in your terminal. This proves two things:
- Anyone who has a JWT can read its payload.
- JWT payloads are not secrets.
Step 5: Decode using the library
Now that you’ve decoded manually, let’s prove the library does the same thing.
Append:
const decoded = jwt.decode(token, { complete: true });console.log("\nlibrary decoded:", decoded);
Explanation:
jwt.decode() does no verification. It just reads base64url and parses JSON. If an attacker edits the payload, decode will happily show the attacker’s data. This is why decode is not security.
Run again and confirm your manual decode and library decode match.
Step 6: Verify the token (this is where trust begins)
Append:
try {const verified = jwt.verify(token, SECRET, { algorithms: ["HS256"] });console.log("\nverified payload:", verified);} catch (err) {console.error("verification failed:", err.message);}
Explanation:
jwt.verify() recomputes the signature using the secret. If the signature matches, it returns the payload. If not, it throws an error. This single function call is the difference between reading data and trusting data.
If this prints your payload, the signature is valid. This is the exact moment authentication systems decide whether a request is legitimate.
Step 7: Tamper with the payload and watch verification fail
Now we will change the payload but keep the old signature. This simulates an attacker editing claims.
Append:
function tamperPayload(token) {const [h, p, s] = token.split(".");const payloadText = base64UrlToString(p);const payloadObj = JSON.parse(payloadText);payloadObj.role = "admin"; // attacker changeconst nextPayload = Buffer.from(JSON.stringify(payloadObj)).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");return `${h}.${nextPayload}.${s}`; // old signature kept}const tampered = tamperPayload(token);console.log("\ntampered decoded:", jwt.decode(tampered));try {jwt.verify(tampered, SECRET, { algorithms: ["HS256"] });console.log("this should not print");} catch (err) {console.error("expected failure:", err.message);}
Explanation:
The decoded tampered token will show role: admin. That proves decode is not protection. But verification will fail because the signature no longer matches. This is the security property JWTs provide.
Step 8: Verify with the wrong secret
Append:
try {jwt.verify(token, "wrong-secret", { algorithms: ["HS256"] });} catch (err) {console.error("wrong secret failure:", err.message);}
Explanation:
Even though the token is perfectly formed, using the wrong secret fails verification. This proves only the server that owns the secret can validate tokens.
Step 9: Watch expiration fail
Your token was created with expiresIn: "15s". Wait 20 seconds and run the file again. You will now see verification fail with jwt expired.
Explanation:
Expiration is enforced during verification, not during decode. An expired token can still be decoded, but it cannot be trusted.
What each part actually means
Header
The header tells the verifier which algorithm to expect. Example:
{"alg":"HS256","typ":"JWT"}
If your server expects HS256 but receives a token claiming a different algorithm, verification should reject it. That’s why we pass algorithms: ["HS256"] to verify.
Payload
The payload is your claim set. It is visible data. It is not encrypted. Never store passwords, secrets, or private data in a JWT payload. Only store identifiers and roles you are comfortable being readable by clients.
Signature
The signature is the cryptographic seal. Any change to header or payload breaks it. This is the only reason JWTs are safe for authentication.
Decode vs Verify
Decode answers: “What does this token claim?”
Verify answers: “Was this claim set issued by a trusted signer and still valid?”
Auth systems must always call verify before trusting anything from a token.