Next.js SaaS Security Checklist

We just finished a hardening pass on Skylos Cloud, our Next.js and Supabase app for AI code security workflows.

The most useful lesson was not "we found one scary bug." It was this:

Most SaaS security regressions happen when a new feature silently bypasses an old invariant.

A billing page ships without the same permission check as the billing API. A public analytics component inherits auth callback URLs. A Supabase RPC duplicates role checks but forgets the plan gate. A rate limit starts using an attacker-controlled bucket. A share token is treated like a bearer secret in the app but remains selectable through the public database API.

None of those require an exotic exploit chain. They are the normal failure modes of fast SaaS teams, especially teams using AI coding tools to move quickly.

This is the Next.js SaaS security checklist we took from that work.

It is written for teams building with Next.js, Supabase, Vercel, GitHub, Stripe or Lemon Squeezy style billing, public API routes, dashboards, and AI-assisted pull requests. You can apply most of it even if your stack is different.


The short checklist

AreaSecurity question to ask
Auth and invite URLsCan any third-party script receive OAuth codes, invite tokens, reset tokens, or sensitive next values?
Route authorizationDoes every sensitive page enforce the same permission as the API route for that feature?
Supabase RLSDo table policies enforce tenant, role, status, and ownership invariants, or only "is a member"?
Public RPCsCan a user call the RPC directly and skip plan, role, or lifecycle checks from the route handler?
Billing entitlementsDoes refund, chargeback, failed payment, and reconciliation logic revoke access even when credit clawback fails?
Public quotasCan attackers vary a repo, org, URL, email, token, or bucket name to reset the limit?
Uploaded metadataCan a report or webhook choose a trusted family, source, author, severity, or billing classification?
Expiry and revocationDoes expiry change durable state, or only display a label that says "expired"?
Shared dataCan public database clients enumerate tokens or select private columns from shared rows?

If you only take one thing from this post, take this:

Test the bypass path, not just the intended UI path.

For a Next.js and Supabase app, that means testing direct PostgREST calls, direct RPC calls, public anon reads, server actions, route handlers, webhooks, and background reconciliation paths.


1. Treat analytics URLs as sensitive data

Analytics feels harmless because it does not look like an auth feature.

That is exactly why it is easy to get wrong.

If a global analytics component runs on every route, it can see URLs from flows that were never meant to be marketing pages:

  • /auth/callback?code=...
  • /invite/<token>
  • /reset-password?token=...
  • /magic-link?token=...
  • /login?next=/invite/<token>
  • /checkout/success?session_id=...

Vercel Analytics supports a beforeSend hook for filtering or modifying event data before it is sent. The mistake is using that hook only as a pathname allowlist while returning the original event URL unchanged.

The safe rule is stricter:

Sensitive routes should be dropped from analytics entirely. Allowed marketing routes should have query strings and fragments stripped before the event is sent.

Use an explicit denylist for auth and account flows:

const excludedPrefixes = [
  "/api",
  "/auth",
  "/dashboard",
  "/invite",
  "/login",
  "/reset-password",
  "/settings",
  "/_next",
];

Then sanitize allowed events:

function sanitizeAnalyticsUrl(input: string): string | null {
  const url = new URL(input, "https://example.com");

  if (excludedPrefixes.some((prefix) => url.pathname.startsWith(prefix))) {
    return null;
  }

  return url.pathname;
}

This is not only an OAuth concern. Invite links, checkout IDs, email verification links, and internal redirect targets can all carry secrets or sensitive workflow context.

For public references, see Vercel's Analytics package documentation, which documents event modification through beforeSend.


2. Put billing authorization on every billing surface

Billing security is not only payment security.

In a SaaS app, billing data is also business-confidential data:

  • current plan
  • credit balance
  • transaction history
  • refund reasons
  • order IDs
  • project or scan IDs tied to usage
  • balance after each transaction
  • who triggered a billing action

If your app has manage:billing, every billing page and API route needs to enforce it. It is not enough for checkout creation to require billing permission if the billing history page only checks workspace membership.

The review question is simple:

Can a viewer, member, or non-billing admin directly visit the billing route and see data intended for owners?

Search for billing reads and writes:

rg "credit_transactions|checkout|billing|plan|credits|refund" src/app src/lib

For each result, write down the required permission. If the answer is "any member can see it," make sure that is product intent rather than an accidental missing check.

In a team SaaS, same-tenant disclosure still matters. A member seeing the whole billing ledger is not a cross-tenant breach, but it is still an RBAC bypass if the product promises owner-only billing controls.


3. Make refund rollback independent from credit clawback

Refund handling often fails because engineers make it too transactional in the wrong direction.

A common flow looks like this:

  1. Customer buys credits.
  2. App grants credits and paid workspace access.
  3. Customer spends some credits.
  4. Customer receives a refund.
  5. Refund handler tries to deduct the full purchased credit amount.
  6. Deduction fails because the balance is now lower.
  7. Refund handler exits before revoking paid access.

That turns a reconciliation problem into an entitlement bypass.

For billing security, refund state and entitlement state must not depend on a perfect credit clawback. You usually want one of these designs:

  • allow the credit balance to go negative
  • record a debt or reconciliation transaction
  • mark the purchase refunded first, then reconcile balance
  • revoke the paid entitlement even when balance reconciliation fails
  • alert an operator for manual collection without leaving paid access active

The invariant should be:

If the provider says the purchase is refunded, the paid entitlement must be removed unless another completed purchase independently grants it.

This applies to subscriptions too. Failed payment, cancellation, chargeback, downgrade, and refund paths should all be tested as first-class authorization paths, not afterthoughts.


4. Do not trust route handlers to protect public Supabase RPCs

Supabase makes it easy to call Postgres functions through RPC.

That is useful. It also means route-handler authorization is not the whole boundary if authenticated users can call the same RPC directly.

The dangerous pattern is:

  1. Next.js route checks requirePlan("pro").
  2. Route calls supabase.rpc("do_sensitive_mutation").
  3. The database function checks only membership or role.
  4. A user calls /rest/v1/rpc/do_sensitive_mutation directly.
  5. The plan gate never runs.

If a function performs a privileged mutation, the function itself needs the invariant.

That includes:

  • tenant membership
  • required role
  • required plan or entitlement
  • request status
  • non-self-approval rules
  • object ownership
  • expiry and revocation rules

This is especially important for SECURITY DEFINER functions. Those functions can perform writes that normal RLS would block, so their internal checks are the security boundary.

Supabase documents Row Level Security as a core authorization mechanism. Treat RPC functions the same way: if clients can invoke them, they need explicit authorization logic or tightly scoped execute grants.

Good hardening steps:

  • revoke execute from public where the function should be server-only
  • call server-only RPCs through the service role from trusted route handlers
  • duplicate plan and role checks inside public mutation functions
  • add direct RPC tests that bypass the Next.js route
  • log rejected RPC decisions with safe metadata

If the direct RPC call can do something the UI route would reject, the app has a security bug.


5. RLS must encode the full tenant invariant

Weak RLS often looks secure at first glance:

with check (
  requested_by = auth.uid()
  and exists (
    select 1
    from organization_members
    where org_id = policy_exception_requests.org_id
      and user_id = auth.uid()
  )
)

That proves the caller belongs to org_id.

It does not prove that every referenced object belongs to the same org.

If a row contains independent foreign keys like:

  • org_id
  • project_id
  • issue_group_id
  • scan_id
  • finding_id

then the policy or database constraints must prove those references line up.

Otherwise a user may be able to create a row under their own org that points at another tenant's object ID. Even if they cannot read the victim object, global unique indexes, foreign key errors, audit trails, notifications, or queue state can turn that mismatch into a cross-tenant integrity or availability issue.

Safer patterns:

  • use composite foreign keys that include org_id
  • add constraints or triggers that validate ownership relationships
  • write RLS with check clauses that join referenced tables
  • make unique indexes include tenant scope where appropriate
  • treat object IDs as identifiers, not secrets

The RLS question should not be "does this user belong to an org?"

It should be:

Does this exact row preserve every tenant and ownership relationship the application assumes?


6. Public quotas need global and source-scoped limits

Per-target rate limits fix one class of abuse and can introduce another.

Suppose a public endpoint accepts a GitHub repository URL:

{
  "url": "https://github.com/owner/repo"
}

If the durable hourly quota key is derived only from owner/repo, an unauthenticated caller can rotate through many public repositories and receive a fresh quota for each one.

That matters when an accepted request performs expensive work:

  • clone a repository
  • run a scanner
  • call an LLM
  • write database rows
  • enqueue jobs
  • send notifications
  • generate previews

A concurrency limit is not the same as an hourly work limit. Concurrency limits simultaneous work. They do not stop sustained work over time.

For public endpoints, use layered quotas:

  • global hourly quota
  • per-IP or per-source quota
  • per-target quota
  • concurrency lease
  • request body limit
  • timeout
  • job cost limit
  • abuse monitoring

The safe design is not "global or per-target." It is usually both.

If you let the request choose the quota bucket, assume the bucket is attacker-controlled.


7. Uploaded metadata is untrusted, even from authenticated uploaders

Authenticated upload does not make the uploaded report trusted.

This matters for any SaaS that accepts build artifacts, security reports, SBOMs, test results, coverage files, provenance metadata, or webhook payloads.

Dangerous fields include:

  • tool
  • source
  • family
  • author_email
  • severity
  • is_new
  • is_suppressed
  • baseline
  • score
  • metadata
  • plan
  • project_id
  • org_id

The uploader may be a valid CI job. The payload can still be malicious, compromised, or simply wrong.

Do not let uploaded metadata choose trusted server behavior without validation. For example:

  • derive scan family from the ingestion path, not only body.tool
  • validate that a "debt" or "defense" report cannot carry normal security findings unless that is intended
  • cap and validate author fields before storing them
  • ignore client-supplied suppression or baseline flags
  • normalize arrays and JSON fields with size limits
  • keep billing classification server-side

The pattern to avoid is:

const tool = body.tool;

if (!supportsBaselineComparison(tool)) {
  skipBaselineComparison();
}

If the client can set tool, the client can influence baseline behavior.

The same rule applies to author attribution. If a report carries blame_email, store it as untrusted scanner-provided metadata unless you validate it against the repository, commit, or provider identity.


8. Expiry must change durable state

Expiry bugs often happen because the UI is correct and the database is not.

An exception request may show as expired because the app computes:

status === "approved" && expiresAt <= Date.now()

But if approval also wrote durable state like this:

  • findings.is_suppressed = true
  • issue_groups.status = "suppressed"
  • active suppression rows
  • queue links
  • audit state

then the app has not actually expired the exception. It has only changed the label.

Downstream views may keep hiding the issue because they filter on durable fields:

  • show only status in ("open", "pending_exception")
  • show only is_suppressed = false
  • preserve previous suppressed status during upsert

The fix is to make expiry a real lifecycle transition.

Options:

  • scheduled job to expire suppressions and reopen affected groups
  • read path that treats expired suppressions as inactive and also repairs stale state
  • database function that atomically expires linked suppressions
  • revoke endpoint that allows cleanup of expired approvals
  • report ingestion that reopens groups when no active suppression remains

The invariant is:

A time-limited approval must stop affecting security visibility when the time limit passes.

If "expired" exists only in the UI, it is not a security control.


9. Public sharing should use safe views, not broad table SELECT

Sharing features are easy to under-design.

The application page may select only safe fields:

select("id, branch, commit_hash, stats, quality_gate_passed")

But if anonymous users have table-level SELECT and RLS allows every row where is_public = true, a direct Supabase REST client may be able to request different columns:

GET /rest/v1/scans?select=id,share_token,result

Postgres RLS is row-level. It does not automatically hide sensitive columns.

For public sharing, prefer one of these designs:

  • public-safe view with only intended columns
  • RPC that requires the share token as an argument and returns a shaped response
  • separate public table populated with sanitized data
  • revoked table grants for anon
  • column-level privileges where appropriate

Avoid putting bearer-like share tokens in a row that anonymous users can enumerate. If possession of the token grants access, the token itself cannot be listable.

Also be careful with "raw result" columns. A UI can omit them, but direct database clients may still select them unless privileges prevent it.


Why AI-assisted teams should care

None of these bugs require AI to exist.

AI-assisted development just makes them easier to ship.

An AI coding tool is good at making a new page match nearby patterns. It may see that most dashboard pages call ensureWorkspace() and use that. It may not notice that billing pages also need manage:billing.

It may generate a Supabase RLS policy that checks membership but misses cross-object consistency.

It may move mutation logic into an RPC and preserve the happy path while dropping the plan gate.

It may add analytics globally because the component is small and the build passes.

That is why security review has to be diff-aware. You are not only looking for vulnerable code in the final snapshot. You are looking for boundaries that disappeared during the change.

Skylos is built around that idea: local-first scanning, PR guardrails, dead-code detection, and AI regression checks for teams using AI-generated or AI-assisted changes in production repos.

For more on the agent side of this problem, see our AI coding agent security checklist and AI code review security PR checklist.


A practical review workflow

Use this workflow when reviewing a new SaaS feature.

1. Identify the trusted state

Write down the state the feature changes:

  • plan
  • credits
  • role
  • membership
  • suppression
  • invitation
  • token
  • share status
  • scan classification
  • billing transaction
  • public queue item

If the feature changes trusted state, it needs security tests.

2. List every entry point

For the same feature, list all ways it can be reached:

  • UI page
  • route handler
  • server action
  • webhook
  • Supabase table API
  • Supabase RPC
  • background job
  • CLI upload
  • public anonymous route

Then ask: does each entry point enforce the same invariant?

3. Test direct calls

Do not only test the intended UI path.

Test:

  • direct table insert
  • direct table update
  • direct RPC call
  • forged webhook body with invalid signature
  • authenticated request with lower role
  • same-tenant request with wrong role
  • cross-tenant object IDs
  • expired state
  • refunded state
  • public anon query
  • oversized uploaded metadata

The bypass path is usually shorter than the normal path.

4. Add contract tests

Good security regression tests read like product rules:

  • "A viewer cannot read billing history."
  • "A public analytics event never includes /auth or /invite URLs."
  • "A refunded purchase revokes workspace access even if credit clawback fails."
  • "A direct RPC call cannot approve an exception for a free workspace."
  • "An RLS insert cannot reference an issue group from another org."
  • "A shared scan public query cannot select raw result JSON."
  • "Expired exceptions do not keep issue groups suppressed."

These are better than tests that only assert implementation details.

5. Scan the diff before merge

Run deterministic checks in CI before a human spends attention on the PR.

Look for:

  • removed tenant filters
  • changed RLS migrations
  • new SECURITY DEFINER functions
  • new public routes
  • new webhook handlers
  • new analytics components
  • new billing state transitions
  • new uploaded metadata fields
  • new rate-limit bucket derivation
  • broad select("*") queries over large JSON columns

This is where a PR guardrail helps. The goal is not to replace human review. The goal is to make sure the obvious security contract breaks are visible before the review starts.


The checklist we now use

When a Skylos Cloud change touches auth, billing, governance, uploads, sharing, or public routes, we ask:

  1. What trusted state can this change mutate?
  2. What user-controlled input reaches that state?
  3. Can the same mutation be reached outside the route handler?
  4. Does RLS enforce the same tenant and role invariant?
  5. Does the RPC enforce the same plan and lifecycle invariant?
  6. Can anonymous users select columns the UI did not intend to expose?
  7. Can an attacker rotate a quota bucket?
  8. Can a refund, expiry, revoke, or rollback path leave stale access?
  9. Can analytics receive a secret-bearing URL?
  10. Can uploaded metadata choose a trusted server behavior?
  11. Can a low-privileged same-tenant user read or write more than intended?
  12. Do tests cover the failure path, not only the success path?

That list is not glamorous. It is effective.

Most SaaS incidents do not start with a movie-grade exploit. They start with one missing check in a new path that looked just like the old path.

The way to prevent that is to make security contracts explicit, then test the paths that bypass your favorite abstraction.