Outrank.so · 2026-05-04
Supabase RLS quota bypass and unauthenticated Notion OAuth state forgery on an SEO SaaS
Four findings, two of them High, sharing one architectural root cause: authorization enforced in the Next.js app layer, missing from the database tier and from several public API routes. An unauthenticated attacker can hijack a victim's Notion publishing pipeline without ever signing into Outrank.
Editor’s note on redactions: Live Supabase JWTs, researcher account UUIDs (org / product / user), the public Supabase anon key bound to those accounts, and the second researcher account used to prove cross-account injection have been redacted from this public version. The vendor received the unredacted report with full PoC code. Identifiers shown below are illustrative placeholders, not real values.
Target: outrank.so — AI SEO content SaaS.
Disclosure status: Vendor notified via DM + email. No response after extended window. Published under the 7-day non-response policy.
Executive summary
Outrank is an AI SEO writing SaaS built on Next.js (Vercel) + Supabase + Stripe, integrating Notion / WordPress / Webflow / Shopify as publishing targets. Four findings, two of them High, share a single architectural root cause: authorization is enforced in the Next.js application layer, but the database layer and several API routes do not enforce the same constraints. An authenticated user can bypass plan quotas by writing directly to Supabase, and an unauthenticated attacker can hijack a victim’s Notion publishing pipeline without ever signing into Outrank.
Architecture
| Component | Detail |
|---|---|
| Frontend | Next.js App Router |
| Hosting | Vercel, fronted by Cloudflare |
| Database | Supabase (api.outrank.so CNAME → *.supabase.co) |
| Auth | Supabase Auth (Google OAuth, JWT in sb-api-auth-token cookie) |
| Scraping | ScrapingBee (primary) + Puppeteer-on-Vercel (fallback) |
| Payments | Stripe (live mode) |
| Analytics | PostHog |
Findings
F1 — Supabase RLS does not enforce article creation limit (High)
Endpoint: POST https://api.outrank.so/rest/v1/articles
Authentication: Valid Supabase session JWT.
Class: Broken Access Control / Business Logic Bypass.
The articles table accepts direct INSERT via Supabase’s REST API using any valid session JWT. Outrank’s “30 articles per month” plan limit is enforced only in the Next.js API layer — Supabase RLS policies do not include a row count check. Any authenticated user can bypass the monthly cap by writing directly to api.outrank.so. The Supabase publishable anon key is embedded in the public JS bundle (this is normal for Supabase apps — the anon key is not a secret on its own, but it does mean the REST endpoint is reachable from anywhere with just a session JWT).
Additionally, status: "published" is accepted in the INSERT payload, meaning articles can be created in published state without going through Outrank’s generation pipeline.
Reproduction (illustrative — placeholder IDs):
import requests
ANON_KEY = "<sb_publishable_* embedded in Outrank's JS bundle>"
USER_JWT = "<session JWT from sb-api-auth-token cookie>"
PRODUCT_ID = "<your product_id from dashboard>"
ORG_ID = "<your organization_id>"
for i in range(31, 200):
requests.post(
"https://api.outrank.so/rest/v1/articles",
headers={
"apikey": ANON_KEY,
"Authorization": f"Bearer {USER_JWT}",
"Content-Type": "application/json",
"Prefer": "return=representation",
},
json={
"product_id": PRODUCT_ID,
"organization_id": ORG_ID,
"title": f"Article {i}",
"status": "published",
},
)
Confirmed:
- 35 INSERTs in a single run — all succeeded.
- DB count confirmed at 35 after the test.
status: "published"accepted on INSERT (not restricted to draft).- No app-layer rate limit, no RLS row-level check, no credit deduction.
- AI generation does not fire on raw INSERT — articles are created as empty shells. The generation pipeline is enqueued by a separate Next.js API call.
A user on a paid plan can write unlimited article records to the table — including in published state. The business risk depends on what the app’s UI gate counts: if it counts all articles in the current month, pre-inserting drafts can either lock the legitimate user out of their own quota or bypass the gate entirely depending on ordering. Either way, the plan economics rest on enforcement that does not exist at the data tier.
Fix. Add a Supabase RLS policy that enforces the monthly count at the DB layer:
(SELECT COUNT(*) FROM articles
WHERE organization_id = auth.jwt()->'organization_id'
AND created_at >= date_trunc('month', now())) < 30
Also restrict status to draft on INSERT via RLS or a DB trigger — only the server-side generation pipeline should set published. Existing per-org isolation RLS appears correct; this is purely the missing count + status gate.
F2 — Unauthenticated subscription data exposure (Low)
Endpoint: GET https://www.outrank.so/api/subscription/status?organizationId=<id>&productId=<id>
Authentication: None required.
The subscription status endpoint returns full Stripe billing state for any organizationId + productId pair without requiring a session cookie or token.
curl "https://www.outrank.so/api/subscription/status?organizationId=<ORG_ID>&productId=<PRODUCT_ID>"
Response (no auth):
{
"isTrialSubscription": true,
"cancel_at_period_end": false,
"status": "trialing",
"currentPeriodEnd": 1778160342,
"currentPeriodStart": 1777901142,
"subscriptionName": "All in One",
"billingInterval": "month",
"articleAddon": null
}
Leaks: plan name, trial vs paid status, billing interval, period start/end timestamps, addon state.
Precondition: attacker needs a valid organizationId + productId pair. These IDs appear together in client-rendered HTML and in the F4 OAuth state parameter — i.e., this is not a meaningful barrier.
Fix. Require an authenticated session and validate the requesting user belongs to the organization before returning billing data.
F3 — Unauthenticated /api/integrations endpoint (Low → potentially Critical)
Endpoint: GET https://www.outrank.so/api/integrations?product_id=<id>
Authentication: None required.
The integrations endpoint accepts any product_id and returns integration data without authentication. For accounts with no connected integrations, the response is {"status":200,"data":[]}. For accounts with a connected WordPress / Webflow / Shopify / Ghost / Wix / Framer integration, the response shape suggests stored OAuth tokens or API keys may be returned.
Severity not finalized — the researcher account had no integrations connected, and ethical scope forbids using a second researcher account to test against a foreign org. If credentials are returned unauthenticated, this finding upgrades to Critical and is the most severe issue in this report. The vendor is best positioned to confirm.
Fix. Same as F2 — gate on session + org membership.
F4 — Unauthenticated cross-account Notion integration injection via unsigned OAuth state (High)
Endpoints:
POST https://www.outrank.so/api/integrations/notion/auth— OAuth initiationGET https://www.outrank.so/api/integrations/notion/callback— OAuth completion
Authentication: None on either endpoint. Class: Broken Access Control / OAuth State Forgery / Missing Authentication.
The Notion OAuth flow has three independent failures. Combined, they let an unauthenticated attacker inject a Notion workspace into any victim’s Outrank account, hijacking where the victim’s AI-generated articles get published.
Failure 1 — No authentication on OAuth initiation.
POST /api/integrations/notion/auth accepts an organization_id and product_id in the request body and returns a valid Notion OAuth URL. No session cookie required, no check that the org belongs to the caller.
# No cookie, no auth header — still returns a valid OAuth URL with attacker-controlled org_id
curl -X POST https://www.outrank.so/api/integrations/notion/auth \
-H "Content-Type: application/json" \
-d '{"organization_id": "<VICTIM_ORG_ID>", "product_id": "<VICTIM_PRODUCT_ID>", "origin": "dashboard"}'
# Response:
# {"authUrl": "https://api.notion.com/v1/oauth/authorize?client_id=...&state=<base64-encoded victim IDs>"}
Failure 2 — OAuth state is unsigned base64 JSON.
The state parameter is base64({"organization_id": ..., "product_id": ..., "origin": ...}) with no HMAC. The state cannot be tampered with after construction, but nothing stops an attacker from constructing their own state with a victim’s IDs from scratch (as Failure 1 already does for them).
Failure 3 — No authentication on OAuth callback.
GET /api/integrations/notion/callback processes the callback without checking if the user is logged in. It exchanges the Notion auth code for a token and saves it under the organization_id from the state — with no validation that any authenticated user owns that organization.
Confirmed: passing a syntactically valid (but bogus) code returns 400 {"error": "Invalid code"} from Notion’s token endpoint, proving the server reached Notion’s token API before any auth check on Outrank’s side.
Attack chain:
- Attacker obtains victim’s
organization_idandproduct_id(both appear in client-rendered HTML and, courtesy of F2, in unauthenticated subscription responses). - Attacker POSTs to
/api/integrations/notion/authwith victim’s IDs — no auth. - Server returns a valid Notion OAuth URL with victim’s IDs in the unsigned state.
- Attacker opens the URL and authorizes their own Notion workspace.
- Notion redirects to
outrank.so/api/integrations/notion/callback?code=<valid>&state=<victim_ids>. - Callback (no auth) exchanges the code, saves the attacker’s Notion token under the victim’s organization.
- Publishing hijack: every article Outrank publishes for the victim now goes to the attacker’s Notion workspace.
Proof. Two researcher-owned accounts, 2026-05-04:
| Step | Action | Result |
|---|---|---|
| Precondition | Inspect Account B’s notion_integrations row | empty — no integration |
| Attack (authenticated) | Account A POSTs to /auth with Account B’s organization_id | 200 — valid auth URL returned |
| Attack (unauthenticated) | No cookie, same POST with Account B’s org_id | 200 — same valid auth URL |
| State decode | base64(state).organization_id | matches Account B’s org ✓ |
| Callback check | GET callback with bogus code + Account B’s state | 400 “Invalid code” from Notion (no auth gate before that point) |
Both accounts in this test were researcher-owned. The final OAuth-authorize step was deliberately not executed, to avoid completing an actual cross-account compromise. Root cause is proven without it.
Scope. Only Notion uses OAuth via this pattern. WordPress / Webflow / Shopify / Ghost / Wix / Framer integrations use direct API key entry (no /auth endpoint), so they are not affected by this specific vector.
Fix.
- Require authentication on both
/authand/callback; reject requests without a valid session. - Sign the OAuth state with an HMAC using a server-side secret:
state = base64(json) + "." + HMAC(json, secret). Verify on callback. - Validate org ownership on
/auth: the authenticated user’s session must belong to the suppliedorganization_idbefore a URL is generated. - Validate org ownership on
/callback: after exchanging the code, theorganization_idfrom the state must match the authenticated user’s org before persisting the token.
What was tested and was NOT a finding
These showed up clean in this engagement. Listing them so readers know the methodology was broad, not cherry-picked.
| Test | Result |
|---|---|
| CORS null origin on Next.js API endpoints | No ACAO header — not vulnerable |
Supabase cross-tenant RLS on organizations, products, articles | Returns only the caller’s own rows — RLS correctly scoped |
IDOR: /api/products/update with foreign productId | Unauthorized — server-side ownership check |
IDOR: /api/products/toggle-auto-keywords with foreign productId | 404 Product not found or unauthorized |
IDOR: /api/catalog/profile with foreign productId | Product not found |
SSRF: /api/sitemap/validate with internal IPs | ScrapingBee blocks private IPs — 400 |
SSRF: /api/product/details DNS rebinding | Puppeteer ERR_CONNECTION_REFUSED (Vercel sandbox) |
SSRF: /api/competitors/generate | Passes URL to seranking as a domain string, no local fetch |
| Supabase anon-key-only reads (no session JWT) | permission denied for table X — RLS enforced |
Root cause analysis
All four findings share one architectural pattern: the Next.js app layer is enforcing security, the data layer is not.
- F1: Plan quota enforced in app code; missing from Supabase RLS.
- F2: Ownership check missing from a public route.
- F3: Same — ownership check missing from a public route.
- F4: Session check missing from two public routes and state integrity missing from OAuth.
This is the modal failure mode of LLM-scaffolded SaaS: the framework’s “happy path” routes (middleware.ts, the auth provider, the primary API surface) are correctly gated, but alternate routes — OAuth callbacks, webhooks, integration endpoints, direct DB access — were added as one-off snippets and inherit no protection. Fixing them target-by-target is necessary; fixing the pattern (enforce critical constraints at the data tier, sign all OAuth state, require auth on every route by default) is what actually prevents the next variant.
Disclosure timeline
- 2026-05-04 — Research conducted; findings documented.
- 2026-05-05 — Initial vendor contact via X DM to founder + email.
- 2026-05-06 → 2026-05-12 — Follow-up via LinkedIn and a public reply asking the founder to check email (no bug details public). No response received.
- 2026-05-17 — Public disclosure under the 7-day non-response policy. The unredacted report and PoC code remain available to the vendor on request.
Research scope
All testing was conducted from researcher-owned accounts only. The two-account test in F4 used two accounts both belonging to the researcher. The cross-account Notion injection in F4 was demonstrated up to and including state forgery and callback acceptance, but was deliberately not executed through to a full Notion-token exchange against either account, to avoid materially compromising the second test account. The root cause is conclusively proven without that step.
No third-party data was accessed at any point.
For founders
Want one of these before a customer finds it?
A hand-driven audit of your AI-coded SaaS, delivered in 7 days. Starting at $1,500. Find a High or Critical or you don't pay.