A proof of concept is not a screenshot.
It is not a dramatic payload. It is not a paragraph that says “an attacker could.” It is not a local reproduction that never touches the program’s threat model. A PoC is the contract between what you claim, what you tested, what the rules allowed, and what the program can verify.
This post assumes you already understand basic web security testing and bug bounty reporting. I am talking to operators who can find suspicious behavior, but need a sharper gate before calling it a vulnerability.
The claim has to survive contact with evidence
Most weak reports fail because the claim is larger than the proof.
Common examples:
- “Account takeover” when the PoC only shows email enumeration.
- “Authorization bypass” when the test only used one account.
- “Sensitive data exposure” when the exposed data is public profile metadata.
- “SSRF” when the request never reaches an attacker-controlled listener.
- “RCE” when the only evidence is template syntax appearing in an error message.
Those may be leads. They are not finished findings.
A good PoC narrows the claim until it becomes true. If the evidence proves cross-tenant read access, say that. If it proves a lower-privilege user can export records they should not control, say that. If it only proves a debug endpoint leaks version information, do not wrap it in CVE language and hope severity appears.
The report should not ask the triager to believe your confidence. It should force the conclusion from the evidence.
My minimum PoC contract
Before I promote a bug bounty note into a finding, I want five things written down:
| Requirement | Question it answers |
|---|---|
| Scoped asset | Was the target allowed by the program rules? |
| Controlled setup | Did I use accounts, tenants, tokens, and data I was allowed to touch? |
| Reproduction steps | Can another person repeat the result without guessing? |
| Actual response evidence | What did the server return, and why does it prove the claim? |
| Impact boundary | What can an attacker do that they should not be able to do? |
If one is missing, the status changes. It becomes draft, blocked, recon_only, or dead_end. It does not become confirmed because the idea feels right.
The contract is deliberately small. It prevents self-deception.
Controlled state beats clever payloads
Access-control bugs need comparison. One account is rarely enough.
For a BOLA or cross-tenant issue, the useful test state looks more like this:
Tenant A
admin-a@example.test
viewer-a@example.test
record-a-001 owned by Tenant A
Tenant B
admin-b@example.test
viewer-b@example.test
record-b-001 owned by Tenant B
Now the PoC can answer real questions:
- Can
viewer-areadrecord-a-001if policy says only admins can? - Can
admin-areadrecord-b-001across the tenant boundary? - Can
viewer-bmodify a record after losing access? - Does the API enforce authorization on export, update, delete, and detail endpoints consistently?
Without controlled state, the researcher is tempted to use whatever object IDs appear in the application. That is how safe testing turns into unauthorized access to real user data.
Do not do that.
If you need another role, another tenant, or synthetic data, write the blocker. The fact that the next step is blocked does not weaken the operation. It keeps it legal.
A reproducible PoC is boring on purpose
A good reproduction sequence should feel almost mechanical:
1. Log in as the restricted user in the researcher-owned tenant.
2. Create a synthetic record named poc-record-restricted-001.
3. Capture the record ID from the normal application response.
4. Log in as a different controlled user that should not have access.
5. Send the documented API request for that record ID.
6. Observe that the server returns HTTP 200 and the full record body instead of 403.
That is not cinematic. It is useful.
The triager should not need to infer which account owned the record, whether the data was synthetic, whether the endpoint was in scope, or whether the response came from the vulnerable request. The steps should close those gaps.
When possible, include raw request and response pairs with tokens redacted:
GET /api/v1/records/rec_poc_restricted_001 HTTP/1.1
Host: api.example.test
Authorization: Bearer REDACTED
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "rec_poc_restricted_001",
"tenant_id": "tenant-a",
"name": "poc-record-restricted-001"
}
The redaction matters. The structure matters too. A screenshot of the browser console is weaker than the exact request that proves the server made the wrong authorization decision.
Local proof is not always program impact
Local reproduction is valuable for SDKs, parser bugs, client-side issues, and CVE analysis. It can prove that a behavior exists in code.
It does not automatically prove bounty-grade impact.
There is a gap between:
This package accepts a dangerous input pattern in isolation.
and:
The in-scope production service exposes that input path to an attacker and the result crosses a protected boundary.
That gap is where a lot of bad reports are written.
If you can only prove the behavior locally, classify it honestly. It may be a strong candidate. It may justify asking for a controlled asset. It may support a coordinated disclosure to the package maintainer. But if the program pays for impact against its assets, the PoC has to reach that bar or state the blocker plainly.
Stop before the PoC becomes harmful
Some proof paths get dangerous fast.
If the next step would access non-owned data, execute a destructive action, trigger production side effects, enumerate at scale, bypass authentication for a real user, or attempt code execution on a live service, stop. Preserve the evidence you already have. Write the exact escalation question.
A safe escalation note is specific:
Current evidence: controlled user can cause the server to fetch a researcher-owned URL.
Risk: the next step would test access to internal network metadata.
Requested approval: permission to perform one non-destructive request to a benign canary URL only.
Stop condition: no scanning, no cloud metadata paths, no credential access, no repeated probing.
That is better than quietly going deeper because the bounty might be higher.
Scope is law. Impact does not excuse crossing it.
The PoC should define the severity, not decorate it
Severity should come after proof.
Start with the evidence and ask what changed for the attacker:
- Did they read data they should not read?
- Did they modify state they should not control?
- Did they escalate privilege?
- Did they cross a tenant, organization, or account boundary?
- Did they obtain a token, session, secret, or durable access path?
- Did they cause financial, privacy, or integrity impact?
If the answer is no, the report may still matter, but the severity is lower. Version disclosure, missing headers, verbose errors, and sample-code weaknesses can be legitimate security work. They are not automatically bounty-grade.
The PoC is the severity engine. Let it be cold.
The clean status vocabulary
I use a small set of labels because vague labels create false readiness:
| Status | Meaning |
|---|---|
recon_only | Context collected. No vulnerability claim. |
draft | Hypothesis with partial evidence. Not proven. |
blocked | The next safe step requires a specific asset, scope answer, or approval. |
confirmed | Working scoped PoC with reproducible impact. |
submitted | Sent to the program after review. |
dead_end | Disproved or no longer worth pursuing. |
The hard rule: confirmed requires a PoC that satisfies the contract.
Not a feeling. Not a likely bug. Not a note with a good title. Evidence.
The standard
A PoC should make three people safer:
- The researcher, because it keeps testing inside authorization boundaries.
- The triager, because it removes guesswork and reduces back-and-forth.
- The program, because it proves an actual risk they can reproduce and fix.
If your proof does not do those things, sharpen it or downgrade the claim.
The best bug bounty reports are not loud. They are constrained. They say exactly what happened, exactly how to reproduce it, exactly why it matters, and exactly where the proof stops.
That is the contract.