Two Queries From an Email to a Stranger's Tax ID
A seller portal let me look up any vendor by email — and one field in the response was the key that unlocked their tax ID.
Target anonymized · a global marketplace's seller portal
The target was the seller onboarding portal for a global marketplace — the place where vendors register their business, verify their identity, and file the tax paperwork that lets them get paid. That last part is what makes these portals interesting. A place that collects tax IDs, legal names, and phone numbers is a place where an authorization slip doesn't just leak a username; it leaks the kind of data that ends up in identity-theft kits.
The whole onboarding experience was driven by a single GraphQL endpoint. One URL, many operations, everything funneled through POST /graphql. GraphQL is a gift to a hunter for exactly this reason: the authorization logic that a REST API spreads across dozens of route handlers gets concentrated into resolvers, and resolvers are easy to write and easy to forget to guard.
The spark
While clicking through my own onboarding, I watched the front-end fetch my profile. The request that caught my eye was a supplier-lookup query, and its variable wasn't a session token or an internal account ID. It was my email address.
{ "query": "query($input: String!){ get_supplier_fetchSupplierDetails(input:$input){ firstName lastName phone vendorDetails { marketplace vendorName supplierPGuid } } }",
"variables": { "input": "me@example.com" } }
An email is a natural key — the sort of value a developer reaches for because it's convenient and unique. But it's also a value every user gives out constantly. The instant I saw an email used as the lookup handle, one question lit up: does the server check that this email belongs to me, or does it just look it up?
Digging in
My first hypothesis was the boring, safe one: the resolver probably scopes results to the authenticated session and only echoes the email I'm allowed to see. So I swapped in a second test account's email and replayed the request with my original session cookie.
The response came back with the second account's real details. No scoping. The input was a direct object reference, and there was no authorization tying the caller to the record.
Then I hit my dead end. The obvious prize on an onboarding portal is the tax ID — and this query didn't return one. I nearly filed it as a mid-severity PII leak (name, phone, vendor name) and moved on. But something nagged: my own account showed a null tax ID too, and I knew why — I'd never completed the tax form. The absence of the field on my account wasn't proof the data was protected. It was proof I was testing with the wrong account.
The refinement was to look harder at what the first query did return: a field called supplierPGuid. A long, opaque, base64 blob — the sort of thing that looks encrypted and therefore feels safe. That "feels safe" is the trap. It was still just an identifier.
The exploit
There was a second resolver, get_supplier_fetchSupplierTaxDetails, and it took exactly that supplierPGuid as its input. Same endpoint, same missing authorization check. The two operations chained into a clean two-hop attack: email → supplierPGuid → tax details.
POST /graphql HTTP/2
Host: seller.example.com
Content-Type: application/json
{"query":"query($in: SupplierDetailsInput!){ get_supplier_fetchSupplierTaxDetails(supplierDetailsInput:$in){ countryOfIncorporation taxId } }",
"variables":{"in":{"supplierPGuid":"<GUID_FROM_HOP_1>","marketplace":"US"}}}
Hop one turns an email — the single most public identifier a person has — into an opaque GUID. Hop two turns that GUID into a country of incorporation and a tax ID. Start with victim@example.com, finish with a stranger's federal tax identifier, and nothing in between ever asked whether I was allowed to.
An email address is something your users hand out on business cards. The moment it doubles as the key that returns someone's tax ID, you don't have authentication with a lookup — you have a public search engine for PII.
Impact
With nothing but a target's email — trivially harvestable from a vendor's public storefront — an attacker could pull their full legal name, phone number, marketplace, registered vendor name, and, for any fully-onboarded seller, their tax ID and country of incorporation. That's a ready-made dossier for tax fraud, targeted phishing, and business-identity theft, harvestable at scale by iterating over a list of seller emails. The opaque GUID gave the illusion of protection while doing none of the work of it.
The fix
The bug isn't the email input; it's the missing authorization. Every resolver that returns supplier data must enforce object-level authorization server-side: derive the caller's identity from the authenticated session, and return records only where the requested supplier belongs to — or is explicitly delegated to — that principal. The supplierPGuid should be scoped the same way; being unguessable is not being authorized. And self-service lookup by a shared identifier like email should simply not exist for anything beyond the caller's own record. Object-level authorization is a per-resolver responsibility, and in GraphQL that means writing the check in every resolver, every time — not trusting the shape of the input to keep the honest people honest.
All identifiers, targets and payloads in this post are anonymized or defanged. Findings were reported and resolved through responsible disclosure.