Upgrading Myself to Enterprise — Exploiting Spur.us's (Stigg's) Billing API
Upgrading Myself to Enterprise — Exploiting Spur.us's Billing API
After my previous dive into Spur's Monocle captcha system, I figured I'd take a look at what's going on behind the authenticated dashboard. The main GraphQL API turned out to be fairly locked down, but things got interesting when I started looking at their billing provider.
Starting Point: The Spur GraphQL API
Spur's dashboard runs on a GraphQL API at app.spur.us/api/graphql. The primary query used for IP lookups looks something like this:
query Search($q: String!) {
search(q: $q) {
... on IPContext {
ip
risks
infrastructure
}
}
}
First thing I tried was introspection — the standard { __schema { types { name } } } query that dumps the entire GraphQL schema. Disabled. Spur's running Apollo Server and they've turned it off, which is the right call.
Array batching (sending multiple queries as a JSON array) was also disabled. So far, fairly standard hardening.
Blind Schema Extraction via Validation Errors
Here's where Apollo Server has an interesting quirk though. Even with introspection disabled, if you send a query with an invalid field name, the server helpfully responds with "Did you mean X?" suggestions. This is Apollo's built-in validation trying to be helpful, but it effectively leaks the schema one piece at a time.
By sending queries with intentionally wrong field names and watching what the server suggests, I was able to map out the entire root schema:
# Probing with invalid names to trigger suggestions
{ mee { id } } # → "Did you mean 'me'?"
{ invoice { id } } # → "Did you mean 'invoices'?"
This revealed three root queries (search, me, invoices) and three mutations (createUser, createOrganization, log). The createUser mutation takes an interesting argument — monocle: String! — which lines up with the org_..._last_monocle_app_id cookie I'd noticed. "Monocle" isn't just the captcha, it's the internal product codename.
The Spur API itself had solid resolver-level auth though. Every query except search returned UNAUTHENTICATED regardless of what session cookies I sent. One interesting exception: createUser(monocle: "test") returned INTERNAL_SERVER_ERROR instead of UNAUTHENTICATED — a different code path, suggesting it gets further through the auth chain before failing. Didn't pursue this further but it's worth noting.
Alias Batching: 10x Rate Limit Bypass
While array batching was disabled, alias batching worked perfectly. GraphQL aliases let you run the same query multiple times in a single request under different names:
{
a: search(q: "1.1.1.1") { ... on IPContext { ip risks } }
b: search(q: "8.8.8.8") { ... on IPContext { ip risks } }
c: search(q: "9.9.9.9") { ... on IPContext { ip risks } }
}
Confirmed working with 10 parallel lookups per request. This effectively bypasses any per-request rate limiting and allows bulk IP enrichment at 10x the intended rate.
Discovering the Second API
The real findings started when I opened the billing page and watched the network tab. Alongside the expected requests to Spur's own API, the browser was making calls to a completely different GraphQL endpoint: api.stigg.io/graphql.
Stigg is a third-party billing and entitlement platform. Spur uses it to manage subscriptions, plan limits, and Stripe integration. The authentication model is different from Spur's — instead of Clerk JWTs, Stigg uses a two-part system:
- x-api-key: A client-side SDK key embedded in the frontend bundle
- x-customer-key: An HMAC-SHA256 signature scoped to the organization
Stigg defines three identity levels: APP_PUBLIC (just the API key, no HMAC), APP_CUSTOMER (API key + HMAC), and APP_SERVER (full backend access). The browser requests use APP_CUSTOMER.
Full Schema Dump: 1,134 Types
The first thing I tried on the Stigg API was introspection. And it was enabled.
A standard introspection query returned the complete schema — 1,134 types, over 100 queries, and 130+ mutations. Every billing operation, Stripe integration detail, credential type, webhook configuration, and admin operation was laid out in full. I saved the entire dump and started reading.
The schema revealed some interesting types: StripeCredentials, Auth0Credentials, ZuoraCredentials, SnowflakeCredentials — all with fields like clientSecret, privateKey, webhookSecret. These credential types are used by the integrations query, which fortunately turned out to be locked behind APP_SERVER identity. But the fact that the schema exposes their existence at all tells an attacker exactly what to look for.
Mapping the Authorization Boundary
With the full schema in hand, I systematically tested every interesting query and mutation against both APP_PUBLIC and APP_CUSTOMER identities to map what was accessible:
APP_PUBLIC (just the API key, extractable from the JS bundle):
- paywall — returns all four plan tiers (Community, Team, Pro, Enterprise) with full entitlement limits and pricing
- sdkConfiguration — Sentry DSN, widget config
- currentEnvironment — internal environment UUID
APP_CUSTOMER (API key + HMAC from browser session):
- customerPortal — generates live Stripe billing portal session URLs
- getCustomerByRefId — exposes Stripe customer ID (cus_PTlrgqy0fNxamw) and Stripe dashboard URL
- entitlementsState — full usage data (250 manual lookups with 13 used, 100K monocle assessments, 5 seats with 1 used)
- getActiveSubscriptions — subscription IDs, status, plan details
Most dangerous mutations were properly locked to APP_SERVER — reportUsage, grantPromotionalEntitlements, updateOneCustomer, credential extraction via integrations. IDOR across organizations was also properly blocked by the HMAC scoping.
There was one notable gap though.
The Big One: Free to Enterprise
While testing mutations, I noticed that applySubscription returned a validation error about a missing billing period — not an IdentityForbidden error. That's a different response. Identity errors mean "you don't have permission." Validation errors mean "you have permission, you just sent the wrong data."
So I added the missing field:
mutation {
applySubscription(input: {
customerId: "org_2sdcdUr8wFzu6fS3lxxxxxxxxxx"
planId: "plan-spur-intelligence-corporation-enterprise"
billingPeriod: MONTHLY
}) {
subscription {
id
status
plan { refId displayName }
}
}
}
Response:
{
"applySubscription": {
"subscription": {
"id": "5a313dd9-db3f-42e8-aaea-b9547d3xxxxx",
"status": "ACTIVE",
"plan": {
"refId": "plan-spur-intelligence-corporation-enterprise",
"displayName": "Enterprise"
}
}
}
}
Status: ACTIVE. Plan: Enterprise. No payment required — just one mutation with the client-side credentials that any authenticated user already has in their browser.
I also tested Pro and Team — Pro came back as PAYMENT_PENDING, but Enterprise returned ACTIVE immediately. Likely because Enterprise is configured as a "contact us" plan with custom pricing, so there's no payment step to gate it.
The APP_PUBLIC identity correctly blocks this mutation, so you do need the HMAC. But the HMAC isn't a secret — it's derived client-side and included in every request the browser makes to Stigg. Any free-tier user could open devtools, copy it, and upgrade themselves.
Stripe SetupIntent Secrets
While poking at the schema I also found the checkoutState query, which takes a customer ID and plan ID and returns checkout information. Among the fields returned is setupSecret:
{
checkoutState(input: {
customerId: "org_2sdcdUr8wFzu6fS3lxxxxxxxxxx"
planId: "plan-spur-intelligence-corporation-enterprise"
}) {
setupSecret
billingIntegration { billingIdentifier }
}
}
This returned live Stripe SetupIntent client secrets:
seti_1T4ZuADsnRXEAoxxxxxxxxxx_secret_U2fEWfnk1sp743IItwjyaxxxxxxxxxx
seti_1T4ZuBDsnRXEAoxxxxxxxxxx_secret_U2fEK7JXPg6anV3se76Hoxxxxxxxxxx
A new secret is generated on every request. These seti_ tokens are Stripe SetupIntent client secrets — they're meant to be used with Stripe.js on the frontend to attach payment methods to a customer. With these, someone could use stripe.confirmCardSetup() to attach an arbitrary payment method to Spur's Stripe customer object. The secrets also leak the Stripe account identifier (DsnRXxxxxx embedded in the token).
On their own these aren't catastrophic — you can't charge money with a SetupIntent. But combined with the subscription upgrade, an attacker now controls both the plan tier and the payment method on file.
What's Actually Well Protected
Credit where it's due — a lot of things were properly locked down:
- Spur API introspection disabled
- Spur array batching disabled
- IDOR across organizations blocked by HMAC scoping
- Usage manipulation (
reportUsage) locked to APP_SERVER - Entitlement grants (
grantPromotionalEntitlements) locked to APP_SERVER - Customer modification (
updateOneCustomer) locked to APP_SERVER - Credential extraction (
integrations,apiKeys,hooks) locked to APP_SERVER - The main Spur API resolvers enforce auth properly
The issue is specifically that applySubscription and checkoutState are accessible at the APP_CUSTOMER level when they should require APP_SERVER or at minimum some server-side payment validation.
The Full Chain
Putting it all together, the exploitation chain looks like this:
- Extract the Stigg client API key from the frontend JS bundle
- Run introspection to dump the full schema (1,134 types, every query and mutation documented)
- Authenticate as APP_CUSTOMER using the HMAC from any authenticated browser session
- Call
applySubscriptionto upgrade from Free to Enterprise — status returns ACTIVE, no payment - Call
checkoutStateto obtain Stripe SetupIntent secrets for payment method manipulation - Optionally use alias batching on the Spur API for 10x rate limit bypass on IP lookups
The root cause is a third-party billing platform (Stigg) with client-facing mutations that should be server-only, combined with introspection being left enabled in production which made finding it all trivial.
Conclusion
This is a pattern worth watching for — modern SaaS stacks often integrate third-party billing platforms (Stigg, Stripe Billing, Chargebee, etc.) and the authorization boundary between "what the client should be able to do" and "what needs server validation" isn't always drawn correctly. The client SDK having mutation access to subscription management is a design choice that puts a lot of trust in the identity scoping, and in this case it didn't quite hold up.
Spur was notified of these findings and huge credit to Stigg for applying a patch the same day.