June 22, 2026

Authentication Bypass and IDOR for CTF: The Broken Access Control Playbook

Auth bypass and IDOR for CTF: delete client-side login checks, tamper cookies and hidden fields, guess defaults, increment IDs, and force-browse to admin pages.

The login is not the lock you think it is

You hit a CTF web app and it asks you to log in. There is no SQL injection in the username box, no default password on the wiki, and the source looks clean. Before you spend an hour brute-forcing, ask the only question that matters in this category: who is actually checking that you are allowed in, and where does that check run?Most CTF access-control flaws collapse the moment you answer it, because the check runs somewhere you control: in your browser's JavaScript, in a cookie you can edit, in a hidden form field, or in an id parameter the server trusts without verifying it belongs to you.

Here is the fast triage. Run these four moves in order on any login or "members only" page and you will clear the majority of access-control challenges:

# 1) Is the gate in client-side JS? Read the source, find the check, delete it.
curl -s http://target/ | grep -iE 'isAdmin|role|loggedIn|redirect'
# 2) Is your role stored in a cookie? Edit it and resend.
curl -s http://target/admin -b 'admin=1; role=admin'
# 3) Are there default creds? Try the obvious ones first.
curl -s -d 'user=admin&pass=admin' http://target/login
# 4) Does a URL or body carry an id? Increment it (IDOR).
curl -s 'http://target/profile?id=2' -b 'session=...'

The rest of this guide is each of those moves in depth, plus the logic flaws that round out the category. Everything here is part of what the Open Worldwide Application Security Project (OWASP) ranks as the number-one web risk: A01 Broken Access Control. CTF authors love it because the bug is conceptual, not a one-line payload you can copy.

Note: This is the access-control companion to the Cookies and JWT post (how session tokens are signed and forged) and the Web Recon post (how to find the hidden endpoints in the first place). Read those for the mechanics this post assumes.

What is the broken-access-control mindset?

Authentication and authorization are two different questions, and CTF challenges break one or the other. Authentication asks who are you. Authorization asks are you allowed to do this. A login bypass attacks the first. An Insecure Direct Object Reference (IDOR) attacks the second: you are correctly logged in as yourself, but the server lets you act on data that is not yours.

Every access-control bug is the same mistake wearing a different hat: the server trusted something the client controls.

That sentence is the whole category. A cookie, a hidden field, a URL parameter, a JavaScript variable, a request to an endpoint nobody linked to: all of these live on your side of the wire. If the server makes a security decision based on any of them without re-verifying server-side, you win. Your job is to enumerate every value the client sends and ask, for each one, "what happens if I change this?"

The tooling is light. A browser's developer tools, curl, and an intercepting proxy cover everything in this post. The Burp Suite post walks through intercepting and replaying requests, which is the single most useful skill here: once you can see and edit every request, the bugs become obvious.

Key insight: The strongest tell for this whole category is a value that describes you traveling inside a request you control. role=user, id=42, isAdmin=false, account_type=free. The server should derive those from your authenticated session, not read them back from your request. When it reads them back, change them.

When the auth check runs in JavaScript, you can just delete it

The laziest gate of all is a check that runs in the browser. The page ships a script that reads a variable, decides you are not an admin, and redirects you away or hides the secret panel. But that script runs on your machine, in an interpreter you control. You can read it, ignore it, or rewrite it.

A representative example, the kind you find by viewing source:

<script>
const user = { name: 'guest', isAdmin: false };
if (!user.isAdmin) {
document.getElementById('admin-panel').style.display = 'none';
// window.location = '/login'; // sometimes a hard redirect
}
</script>

There are three ways past this, in increasing order of force:

  • Read, do not run. The flag or the next URL is often sitting in the JavaScript itself. curl the page and grep the source. The redirect never fires for curl because curl does not execute scripts. You already have the HTML the "admin" would see.
  • Unhide the element. If the panel is merely set to display:none, open dev tools, find the node, and flip the style. The content was sent to your browser; CSS was the only thing stopping you from reading it.
  • Rewrite the variable. Open the console and run user.isAdmin = true before the gate runs, or set a breakpoint on theif and edit the value live. Even a hard window.location redirect can be cancelled by pausing in the debugger.
# Pull the page without executing any of its JS. The redirect never happens.
curl -s http://target/dashboard | grep -iE 'flag|admin|secret|token'
# In the browser console, defeat a client-side gate before navigation:
// user.isAdmin = true
// document.getElementById('admin-panel').style.display = 'block'
Tip: The single biggest mental shortcut for web CTF: anything the server already sent your browser is yours to read. Hidden panels, greyed-out buttons, commented-out HTML, and data baked into JavaScript bundles all arrived over the wire before any check ran. View source and read every byte.

How do I tamper cookies and hidden fields to become admin?

The next step up from a JavaScript gate is a server that does check on every request, but checks the wrong thing. It reads your role from a cookie or a hidden form field, both of which you fully control. Setting admin=1 should never grant admin, but on a surprising number of challenges it does.

Start by dumping your cookies. A plaintext role is an open door:

# See what the server set
curl -si http://target/login -d 'user=guest&pass=guest' | grep -i set-cookie
Set-Cookie: session=guest; Path=/
Set-Cookie: admin=0; Path=/
# Flip it and request the protected page
curl -s http://target/admin -b 'session=guest; admin=1'

Cookies come in flavors, and each one has a tampering style:

  • Plaintext. admin=0, role=user, auth=false. Edit and resend. This is the level that picoCTF 2021 Cookies teaches: the cookie is just a number, and walking it upward changes who the server thinks you are.
  • Encoded. A blob of base64 or hex that decodes to {"user":"guest","admin":false}. Decode it, edit the JSON, re-encode, resend. The encoding is not security.
  • Signed or hashed. The cookie carries a Message Authentication Code or a JSON Web Token (JWT) signature so you cannot edit it blindly. This is where picoCTF 2021 More Cookies and picoCTF 2021 Most Cookies live. Forging these is the subject of the Cookies and JWT post: weak secrets, the alg:none trick, and known-plaintext attacks on home-rolled schemes.

Hidden form fields are the same idea in a <form>. A registration page that ships <input type="hidden" name="role" value="user"> is handing you the authorization decision. Change value to admin in dev tools, or just send the field yourself:

# The form shipped a hidden role field. Submit it set to admin.
curl -s http://target/register \
-d 'username=attacker&password=pw&role=admin'
# Parameter the form never showed you, but the backend reads anyway:
curl -s http://target/account/update \
-b 'session=...' -d 'email=me@x.com&is_admin=true'
Warning: Always send the field even if the form does not display it. Backends frequently bind every parameter in the request body to an object (mass assignment). A field the designer assumed only the server would set, like is_admin or balance, is writable simply because the handler never excluded it.

Are there default or guessable credentials?

Before anything clever, try the boring keys. CTF authors seed test accounts and forget to remove them, or the challenge is explicitly about a shipped default. The cost is a handful of requests and the payoff is a full bypass.

The short list that clears more challenges than it has any right to:

admin : admin
admin : password
admin : admin123
guest : guest
test : test
root : root / toor
admin : <blank>
user : changeme

Beyond the obvious pairs, look for context the challenge already gave you. A name in the page footer, an email in a mailto: link, a username shown in a comment thread: any of these is a candidate username, often with a weak password. When you need to spray a list, a small wordlist against the login form is fair game:

# Loop a few candidate passwords against a known username
for p in admin password admin123 letmein root; do
echo -n "$p -> "
curl -s -o /dev/null -w '%{http_code}\n' \
-d "user=admin&pass=$p" http://target/login
done
Note: Watch the response, not just the status code. A wrong password and a right one often both return HTTP 200; the difference is a redirect, a Set-Cookie, a changed body length, or the word "Welcome." Diff the responses. The same signal-reading skill powers credential-related challenges across the board.

Default credentials also apply when the "app" is a known piece of software. If recon fingerprints a specific admin panel or device firmware, search the vendor docs for its shipped default login. That is recon work; the Web Recon post covers fingerprinting.

IDOR: what happens when I increment the id?

This is the heart of the post. You are logged in as yourself, legitimately. You view your profile and the URL reads /profile?id=42. You change 42 to 41 and the server hands you someone else's profile. That is an Insecure Direct Object Reference: the server used your supplied id to fetch an object without checking that the object belongs to you.

IDOR is not a login bypass. You are authenticated. The server simply never checked whether the thing you asked for is yours to see.

The reference can live anywhere a request carries an identifier:

# In the query string
GET /profile?id=42 -> GET /profile?id=1
GET /invoice?order=1007 -> GET /invoice?order=1006
# In the path
GET /api/users/42/messages -> GET /api/users/1/messages
# In the request body
POST /account/view body: { "account_id": 42 } -> "account_id": 1
# In a cookie or header
Cookie: uid=42 -> Cookie: uid=1

Concrete walk-through. You register, log in, and capture the request that loads your own data. Then you replay it with the identifier walked down toward 1, which is usually the first user created (frequently the admin):

# Your own profile, with your real session cookie
curl -s 'http://target/profile?id=42' -b 'session=YOUR_REAL_SESSION'
# Same session, someone else's id. If this returns their data, it is IDOR.
curl -s 'http://target/profile?id=1' -b 'session=YOUR_REAL_SESSION'
# Automate the sweep across a range of ids and look for the flag
for i in $(seq 1 50); do
echo -n "id=$i: "
curl -s "http://target/profile?id=$i" -b 'session=YOUR_REAL_SESSION' \
| grep -oE 'picoCTF\{[^}]*\}' || echo '(no flag)'
done

Three refinements that turn a dead IDOR into a live one:

  • Non-sequential ids.If the reference is a Universally Unique Identifier (UUID) or a hash, you cannot guess it by counting. Look for where the app leaks other users' ids: a listing page, a search result, an autocomplete endpoint, a shared link. Harvest the id from one place and replay it at the protected one.
  • Method and endpoint swaps. Read access may be locked but write access forgotten. If GET /api/users/1 is blocked, try PUT /api/users/1 or a sibling route like /api/users/1/export. Access control is often applied per route, not per object, so an unguarded sibling gives you the same object.
  • Encoded or offset ids. An id that is base64, hex, or a fixed offset from the real value is still a direct reference. Decode NDI= to 42, change it, re-encode. The wrapper is decoration.

IDOR also pairs with injection. A NoSQL backend that takes a JSON id may accept an operator instead of a value, turning "fetch my record" into "fetch every record." picoCTF 2024 No SQL Injection is the canonical practice for that crossover; the SQL Injection post covers the injection half.

Key insight: The fastest IDOR confirmation is the two-account test. Register two users, capture the same request as each, and diff them. The only difference is the identifier. Paste user A's identifier into user B's session: if B sees A's data, the server is not checking ownership, and every id in range is now readable.

Forced browsing: what if I just request /admin?

Some pages are protected only by the fact that nothing links to them. There is no button to the admin console, so the developer assumed nobody would find it. This is security by obscurity, and it is defeated by typing the URL. Requesting a resource that is not linked but is not actually access-controlled is called forced browsing.

The candidate paths are predictable. Try them by hand, then with a wordlist:

/admin /admin/ /administrator
/admin.php /admin/index.php /admin/dashboard
/dashboard /panel /manage
/internal /private /debug
/api/admin /api/v1/users /.git/config
/backup /backup.zip /robots.txt

robots.txt deserves a special mention: it is a list, written by the developer, of the exact paths they did not want indexed. That is a map of the interesting endpoints. Always read it first.

# robots.txt often names the very paths you want
curl -s http://target/robots.txt
# Brute-force directories with a wordlist (ffuf or gobuster)
ffuf -u http://target/FUZZ -w wordlist.txt -mc 200,301,302,403
# A 403 is not a dead end: the path exists. Try sub-paths and method swaps.
curl -s -X POST http://target/admin/createUser -d 'user=x&role=admin'

Forced browsing combines with everything above. A path that returns 403 Forbidden to a guest may return 200 once you add a tampered admin=1 cookie. The endpoint existed all along; the check on it was the weak link. The Web Recon post covers content discovery in depth, and picoCTF 2019 logon is a clean example of a page that lets you in once you set the right cookie value.

Warning: A 403 or a redirect to /login tells you the resource is real. Do not discard it. Re-request it with each escalation you have: a forged cookie, a different HTTP method, a trailing slash, a path-traversal prefix, or an X-Forwarded-For: 127.0.0.1 header that some apps trust for "internal only" gates.

Logic flaws: when the rules disagree with reality

The last family is the subtlest. There is no missing check; the check is simply wrong about the world. These bugs come from assumptions the developer baked in that you can violate. They reward reading the application as a set of rules and asking which inputs the rules never anticipated.

  • Negative quantities. A shop charges price * quantity. Set quantity = -1 and the total goes negative, crediting your account instead of debiting it. The same trick on a transfer amount moves money the wrong way. The code validated that you had enough balance, not that the amount was positive.
  • Parameter pollution. Send the same parameter twice: ?role=user&role=admin. Different layers of the stack pick different occurrences. A front-end filter may read the first role while the backend logic reads the last, so the value that passes validation is not the value that gets used.
  • Type juggling. A loosely typed comparison can treat 0 == "admin" (PHP 7 and earlier) or true == 1 as equal. Sending a password as a JSON true or an empty array instead of a string can make a naive equality check pass. Note PHP 8 changed number-to-string comparison, so 0 == "admin" is now false there.
  • Skipping steps. A multi-step flow (cart, then checkout, then pay, then confirm) often guards only the last step. Request the confirmation endpoint directly and the unpaid order ships. The state machine assumed you would arrive in order.
  • Race conditions.A "use this coupon once" check that reads, then writes, can be beaten by firing many requests at once before the write lands. You redeem the same single-use token several times.
# Negative quantity flips a charge into a credit
curl -s http://target/cart/add -b 'session=...' \
-d 'item=1337&quantity=-5'
# Parameter pollution: two roles, two parsers, one of them picks admin
curl -s 'http://target/account?role=user&role=admin' -b 'session=...'
# Type juggling: send the password field as a boolean, not a string
curl -s http://target/login -H 'Content-Type: application/json' \
-d '{"user":"admin","pass":true}'
Key insight: Logic flaws are found by reading, not fuzzing. List every assumption the app makes about your input (positive, in range, the right type, arriving in order, used once) and test each assumption by violating exactly one of them per request. The real-world bug patterns post catalogs more of these with worked examples.

How do I recognize which one I am looking at?

Pattern-match on what the app shows you. Each flaw has a tell that you can spot in the first minute of recon, before you write a single payload.

FlawThe tellFirst move
Client-side checkRedirect or hidden panel driven by inline JS; isAdmin in the sourcecurl the page; edit the variable in console
Cookie / hidden fieldA role or flag value in a cookie or <input type=hidden>Edit the value, resend
Default credsGeneric login with no obvious injection; a username hinted on the pageTry admin/admin and friends
IDORA numeric or encoded id in the URL, body, or cookieIncrement it with your session
Forced browsingHints at an admin area; robots.txt entries; 403 pagesRequest the path directly
Logic flawQuantities, prices, multi-step flows, repeated parametersViolate one assumption per request

When two tells appear together, chain them. A 403 on /admin plus a plaintext role cookie is forced browsing gated by a tamperable check: forge the cookie, request the path. That composition is where most multi-step web challenges live.

picoCTF challenges that drill access control

These on-site writeups each isolate one rung of the playbook. Work them in roughly this order to build the full reflex:

  • picoCTF 2019 logon is the cleanest cookie-tamper bypass: log in as anyone, notice the cookie that decides admin, flip it, and the flag appears. The ideal first challenge for this category.
  • picoCTF 2022 Local Authority puts the authentication decision in client-side JavaScript. The credentials are checked in a script the server sends you, so reading the source is the entire solve.
  • picoCTF 2021 Cookies teaches the plaintext-cookie escalation: the cookie is a number, and walking it upward changes who the server thinks you are.
  • picoCTF 2021 More Cookies and picoCTF 2021 Most Cookies add a signature or encryption layer, pushing you from raw tampering into forging signed tokens. Pair them with the Cookies and JWT post.
  • picoCTF 2024 No SQL Injection is the IDOR-meets-injection crossover: a backend that trusts a client-supplied query object, so you ask it for records that are not yours.

For structured practice beyond picoCTF, the PortSwigger Web Security Academy access control labs are the canonical free training ground, with a graded IDOR and forced-browsing track that mirrors everything here.

Quick reference

Access-control checklist for any login or members page

  1. View source. Is the auth check in JavaScript? Read it, unhide the panel, or rewrite the variable in the console.
  2. Dump cookies and hidden fields. Any role, admin, or auth value you can edit? Flip it and resend.
  3. Try default creds: admin/admin, admin/password, guest/guest, and any username the page leaked.
  4. Find every id in URLs, bodies, cookies, and headers. Increment it with your real session. Diff two accounts to confirm IDOR.
  5. Read robots.txt and force-browse to /admin, /dashboard, /api/admin. A 403 means it exists; re-attack it with your escalations.
  6. Hunt logic flaws: negative quantities, duplicated parameters, type juggling, skipped steps, one-time tokens raced.
  7. When a signed cookie or token blocks you, switch to the Cookies and JWT forging techniques.

curl cheat sheet

# Read a page without running its JS (defeats client-side redirects)
curl -s http://target/dashboard | grep -iE 'flag|admin|secret'
# Tamper a cookie
curl -s http://target/admin -b 'session=...; admin=1; role=admin'
# Send a hidden / mass-assignment field the form never showed
curl -s http://target/register -d 'user=x&pass=y&role=admin'
# Sweep IDOR ids with your own session
for i in $(seq 1 50); do curl -s "http://target/profile?id=$i" \
-b 'session=...' | grep -oE 'picoCTF\{[^}]*\}'; done
# Force-browse and treat 403 as 'exists, attack harder'
ffuf -u http://target/FUZZ -w wordlist.txt -mc 200,301,302,403

The whole category reduces to one habit: enumerate every value your client sends, and for each one ask what breaks if you change it. The server that trusts your input has already lost; you just have to notice which input it trusted.

One concrete move to lock it in. Open any web challenge, register an account, and before you touch a payload, list every place your role or your id travels in a request. That list is your attack surface, and access control is the bug that lives on it.