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

ComponentDetail
FrontendNext.js App Router
HostingVercel, fronted by Cloudflare
DatabaseSupabase (api.outrank.so CNAME → *.supabase.co)
AuthSupabase Auth (Google OAuth, JWT in sb-api-auth-token cookie)
ScrapingScrapingBee (primary) + Puppeteer-on-Vercel (fallback)
PaymentsStripe (live mode)
AnalyticsPostHog

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 initiation
  • GET 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:

  1. Attacker obtains victim’s organization_id and product_id (both appear in client-rendered HTML and, courtesy of F2, in unauthenticated subscription responses).
  2. Attacker POSTs to /api/integrations/notion/auth with victim’s IDs — no auth.
  3. Server returns a valid Notion OAuth URL with victim’s IDs in the unsigned state.
  4. Attacker opens the URL and authorizes their own Notion workspace.
  5. Notion redirects to outrank.so/api/integrations/notion/callback?code=<valid>&state=<victim_ids>.
  6. Callback (no auth) exchanges the code, saves the attacker’s Notion token under the victim’s organization.
  7. 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:

StepActionResult
PreconditionInspect Account B’s notion_integrations rowempty — no integration
Attack (authenticated)Account A POSTs to /auth with Account B’s organization_id200 — valid auth URL returned
Attack (unauthenticated)No cookie, same POST with Account B’s org_id200 — same valid auth URL
State decodebase64(state).organization_idmatches Account B’s org
Callback checkGET callback with bogus code + Account B’s state400 “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.

  1. Require authentication on both /auth and /callback; reject requests without a valid session.
  2. Sign the OAuth state with an HMAC using a server-side secret: state = base64(json) + "." + HMAC(json, secret). Verify on callback.
  3. Validate org ownership on /auth: the authenticated user’s session must belong to the supplied organization_id before a URL is generated.
  4. Validate org ownership on /callback: after exchanging the code, the organization_id from 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.

TestResult
CORS null origin on Next.js API endpointsNo ACAO header — not vulnerable
Supabase cross-tenant RLS on organizations, products, articlesReturns only the caller’s own rows — RLS correctly scoped
IDOR: /api/products/update with foreign productIdUnauthorized — server-side ownership check
IDOR: /api/products/toggle-auto-keywords with foreign productId404 Product not found or unauthorized
IDOR: /api/catalog/profile with foreign productIdProduct not found
SSRF: /api/sitemap/validate with internal IPsScrapingBee blocks private IPs — 400
SSRF: /api/product/details DNS rebindingPuppeteer ERR_CONNECTION_REFUSED (Vercel sandbox)
SSRF: /api/competitors/generatePasses 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.

See the offer