The docs for GET /api/v1/userinfo list one parameter: id. The code behind it may read four. One was added for a mobile client, one was left in for debugging, one switches on an "expanded" view the web app never asks for. None of them show up in the front-end, the OpenAPI file or your proxy history, so none of them get tested.

Hidden parameters are one of the cheapest pieces of attack surface to find and one of the easiest to over-report. This post covers both halves: how discovery tools find them in a few dozen requests instead of thousands, and what it takes to turn "the response changed" into a finding someone will accept.

Everything below uses stand-in hosts (app.example.com) and a local mock. Only run discovery against systems you own or are explicitly authorized to test.

What a "hidden parameter" is

It's any input the server processes that nobody told you about. The OWASP Web Security Testing Guide treats mapping these as part of identifying application entry points (WSTG-INFO-06), and even calls out unusual custom headers such as debug: false as worth noting.

The common sources:

  • Leftovers: debug switches, feature flags, legacy names kept for an old client.
  • Alternate views: fields, expand, include, format, verbose, which change how much of an object comes back.
  • Framework binding: frameworks that bind request parameters straight onto an internal object. PortSwigger's API testing material notes that mass assignment can create parameters the developer never meant to process. (That body-field case has its own article; this one stays on discovery in general.)

You find the obvious ones by reading: JavaScript bundles, old API versions, mobile app traffic, error messages that echo a parameter name. You find the rest by guessing, and guessing well is what the tools are for.

How discovery works: chunk, compare, bisect

Testing 25,000 names one request at a time is slow and noisy. The tools in this space send many names in a single request and use the response to decide where to look.

Here's the idea, as implemented in Arjun by Somdev Sangwan (s0md3v):

  1. Learn what "normal" looks like. Send the baseline request a couple of times and record what stays stable: status code, header names, body, line count, whether a parameter name or value is reflected. Arjun's anomaly.py tracks exactly these factors.
  2. Send names in chunks. Split the wordlist into groups and send each group as one request, every name with a filler value. Arjun's default chunk is 250 names per request for GET and 500 for the other methods (from __main__.py). Its README describes the default wordlist as 25,890 names, built from Common Crawl plus the SecLists and Param Miner lists.
  3. Throw away quiet chunks. If the response matches the baseline, none of those names did anything.
  4. Bisect the loud ones. Split a chunk that changed the response in half, test each half, keep going until you're down to single names.

Chunk, compare, bisect: how parameter discovery narrows thousands of names to a few candidates, then to a proven finding

The maths is why this is fast. With chunks of 250, a 25,890-name list is about 100 requests for the first pass. Each real parameter then costs roughly two requests per halving, and 250 halves to 1 in eight steps. A quiet endpoint with one hidden parameter is done in around 120 requests, not 25,890.

PortSwigger's Param Miner extension for Burp uses the same binary-search approach. Its README says it can guess up to 65,000 names per request, and PortSwigger's API testing page puts it at up to 65,536. It also harvests words from in-scope traffic, so its guesses fit the target. It can guess headers and cookies too, which is where it earns its keep in web cache poisoning research.

The same idea in 40 lines

To make the mechanism concrete, here's a minimal version in standard-library Python. It isn't a replacement for the real tools (no reflection tracking, no rate handling); it's here so you can see every step.

import secrets, sys, urllib.request from urllib.parse import urlencode URL = "http://127.0.0.1:8019/api/v1/userinfo?id=1001" # a stand-in you own requests_sent = 0 def fingerprint(params): global requests_sent requests_sent += 1 url = URL + ("&" + urlencode(params) if params else "") with urllib.request.urlopen(url, timeout=10) as r: body = r.read() # Remove our own random values so a reflected value doesn't count as a change  for v in params.values(): body = body.replace(v.encode(), b"") return (r.status, sorted(r.headers.keys()), body) def random_values(names): return {n: secrets.token_hex(4) for n in names} def changes(names, baseline): return fingerprint(random_values(names)) != baseline def bisect(names, baseline): if not changes(names, baseline): return [] if len(names) == 1: return names mid = len(names) // 2 return bisect(names[:mid], baseline) + bisect(names[mid:], baseline) words = [w.strip() for w in open(sys.argv[1]) if w.strip()] baseline = fingerprint({}) if fingerprint({}) != baseline: sys.exit("Baseline isn't stable; compare fewer things (status, length) instead.") CHUNK = 50 found = [] for i in range(0, len(words), CHUNK): found += bisect(words[i:i + CHUNK], baseline) print("candidates:", found, "| requests:", requests_sent) 

I ran it against a local mock of that userinfo endpoint with a 502-name wordlist. The mock has two undocumented parameters: debug, which adds an internal _debug block whenever it's present, and expand, which only adds the user's email when its value is contact.

candidates: ['debug'] | requests: 25 

25 requests instead of 502, and one parameter found. The other one was missed, and that miss is the most useful thing in the output.

Why a clean run doesn't mean "no more parameters"

expand only reacts to one specific value. Discovery tools fill each name with a placeholder (random hex in my script; Arjun uses numeric filler values, per its populate() helper), so a parameter that ignores anything except contact, true or all looks exactly like a parameter that doesn't exist.

Other common reasons for misses:

  • The baseline isn't stable. Timestamps, CSRF tokens or rotating ads in the body make every response "different". Compare fewer things (status, header names, a normalized body) or strip the volatile parts first.
  • Wrong place. A GET scan won't find a JSON body field. Arjun's -m switches between GET, POST, JSON and XML.
  • Wrong context. Some parameters only matter when logged in, or only on a particular object state. Scan the authenticated request with your own test account's session.
  • Throttling. A WAF or rate limit that starts returning 429s will turn the whole run into noise. Slow down before it happens.

So treat the wordlist run as a first pass, then try a short list of meaningful values (1, true, all, full, admin, names you saw in the JavaScript) on the names that matter for this endpoint.

Running Arjun on a target you're authorized to test

Install it the way its README recommends (pipx install arjun), then:

# Query-string parameters on one endpoint, results saved as JSON arjun -u "https://app.example.com/api/v1/userinfo?id=1001" -oJ userinfo-params.json # JSON body parameters on a POST endpoint arjun -u "https://app.example.com/api/v1/profile" -m JSON -oJ profile-params.json # Scan as your own test user, and keep the request rate low arjun -u "https://app.example.com/api/v1/userinfo?id=1001" \ --headers "Authorization: Bearer <your-test-account-token>" \ --rate-limit 5 -oJ userinfo-authed.json 

A few options worth knowing (names from the current source; run arjun -h on your version, because the wiki and the code don't fully agree on defaults):

  • -w picks a wordlist. It accepts small, medium or large for the bundled lists, or a path to your own. A list built from the target's own JavaScript and docs often beats a generic one.
  • -c sets the chunk size. If the server rejects long URLs, make the chunks smaller.
  • --stable and -d drop to one thread and slow down; --rate-limit caps requests per second. Respect the program's rate rules.
  • --passive pulls parameter names from public sources (Wayback Machine, Common Crawl, OTX) instead of guessing.
  • -oB sends the hits to your Burp proxy so you can carry on testing there.

A hit is a lead, not a finding

Every name the tool reports means one thing: "the response changed when this name was present". That's a lead. It isn't a vulnerability, and reporting it as one is how you get "informative" or "not applicable" back. Detection isn't proof.

To prove it, answer three questions in order.

1. Is it real? Send the parameter alone against a fresh baseline, a few times. Then vary the value. Here's that step against the same mock:

import json, urllib.request BASE = "http://127.0.0.1:8019/api/v1/userinfo?id=1001" # your own test account  def get(extra=""): with urllib.request.urlopen(BASE + extra, timeout=10) as r: return json.loads(r.read()) baseline = get() for extra in ["&debug=1", "&debug=0", "&expand=1", "&expand=contact"]: added = sorted(set(get(extra)) - set(baseline)) print(f"{extra:<17} new fields: {added or '-'}") 
&debug=1 new fields: ['_debug'] &debug=0 new fields: ['_debug'] &expand=1 new fields: - &expand=contact new fields: ['email'] 

Two things fall out. debug=0 still turns debug on, because the server checks for presence, not value. And trying values found expand=contact, which the wordlist run missed.

2. What does it change? Diff the responses and describe the change plainly: extra fields, a different code path, a skipped check, a different price, an internal hostname in an error.

3. Does it cross a security boundary? This is the step that makes it a finding, and you prove it with data you own:

  • Create two test accounts, A and B. Logged in as A, request B's object with the parameter (id=<B's id>&expand=contact). If A now sees B's email and the normal response didn't show it, you've shown property-level data exposure across users (OWASP API3:2023, Broken Object Property Level Authorization).
  • If a debug flag returns internal details, record exactly what's exposed and whether it helps an attacker (credentials and tokens are serious, a replica name usually isn't). Don't go looking for real users' data to make the point.
  • If a parameter skips a check (skip_verification, force), show the before and after on your own account and stop there.

If it doesn't cross a boundary, it's still worth a note for the developers ("unused debug parameter reachable in production"), but don't dress it up as more.

For developers: make hidden parameters boring

You can't stop people from guessing names. You can make guessing pointless.

  • Reject what you don't expect. An allowlist of accepted parameters per endpoint, with unknown names rejected, turns a silent hidden input into a loud 400. FastAPI (0.115.0 and later) supports query parameter models, and its docs show forbidding extras like this:
 from typing import Annotated, Literal from fastapi import FastAPI, Query from pydantic import BaseModel, Field app = FastAPI() class FilterParams(BaseModel): model_config = {"extra": "forbid"} limit: int = Field(100, gt=0, le=100) offset: int = Field(0, ge=0) order_by: Literal["created_at", "updated_at"] = "created_at" @app.get("/items/") async def read_items(filter_query: Annotated[FilterParams, Query()]): return filter_query 

A request with an extra tool=plumbus gets an extra_forbidden error instead of being quietly ignored.

  • Keep debug switches out of production builds. A feature flag read from server-side config can't be flipped by a query string. If a debug view must exist, put it behind an admin role, not a parameter.
  • Authorize the output, not just the request. If expand or fields can return more properties, check that the caller may see each one. Picking a smaller view is fine; widening it needs the same authorization as any other read.
  • Document every parameter you accept. If it's in the OpenAPI spec, it gets tested, by you and by the people you pay to test it.

Try it yourself

Only test what you own or are authorized to test. These are built for it:

Conclusion

Parameter discovery is cheap because of one trick: send many names at once, then bisect only the requests that changed something. Use it on every interesting endpoint, then remember its blind spots: value-gated parameters, unstable baselines, the wrong method, the wrong session. And treat every hit as a question, not an answer. A parameter matters when you can show, on your own accounts, what it lets someone see or do that they shouldn't.

Further reading

I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited.

🔐 If this was useful, follow me for more hands-on web & API security write-ups.

🔗 Connect with me on LinkedIn: https://www.linkedin.com/in/xalgord/