This is a sample scan for demo purposes. Your own scan will show real data from your real app. Run a real scan →
Scan report

https://example-saas.demo

Next.js 14 · Vercel · database: Supabase · scanned 4/17/2026, 2:32:10 PM
Grade
F
Critical
3
High
3
Medium
2
Passed
2
Total
10

8 issues found

criticalData Exposuresupabase_tables_exposed

Supabase tables exposed to the public anon key

5 Supabase tables are readable with the anon key. Row-Level Security is either disabled or missing policies. An attacker can enumerate your entire user base and sensitive records directly from the browser.

Sample rows pulled with the public anon key
users (12,847 rows) — columns: id, email, full_name, phone, stripe_customer_id
  → { "email": "alex.turner@example.com", "full_name": "Alex Turner", "phone": "+1-415-555-0142" }
  → { "email": "priya.sharma@example.com", "full_name": "Priya Sharma", "phone": "+44-20-7946-0321" }
  → { "email": "j.okafor@example.com",    "full_name": "Jide Okafor",   "phone": "+234-803-555-7712" }
subscriptions (4,211 rows) — columns: user_id, plan, stripe_sub_id, amount_cents
invoices (9,032 rows) — columns: user_id, amount_cents, status, card_last4
feedback (1,876 rows) — columns: user_id, email, message
internal_notes (64 rows) — columns: user_id, note, flagged
How an attacker exploits this
1. Attacker opens DevTools on your landing page and copies the NEXT_PUBLIC_SUPABASE_ANON_KEY from the JS bundle.
2. They hit https://<project>.supabase.co/rest/v1/users?select=* with the anon key as the apikey header.
3. Supabase returns all 12,847 rows because RLS is off. They now have every user's email, phone, and Stripe customer ID.
4. They sell the list or run targeted phishing against your highest-paying customers.
How to fix
In Supabase → Authentication → Policies, enable RLS on each exposed table and add explicit policies such as `auth.uid() = user_id`. Never rely on client-side filtering. Regenerate the anon key after you verify lockdown.
criticalSecretsapi_env_exposed

.env.local is publicly reachable

GET https://example-saas.demo/.env.local returned 200 OK with real environment contents. This is the worst possible misconfiguration — every secret you have is now public.

Contents of /.env.local fetched without auth
NEXT_PUBLIC_SUPABASE_URL=https://abcdefghijklmnop.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSJ9.FAKE-DEMO-ANON-KEY
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIn0.FAKE-DEMO-SERVICE-ROLE
STRIPE_SECRET_KEY=sk_live_51AbCdEf0000000000000000DEMOKEY
STRIPE_WEBHOOK_SECRET=whsec_DEMO000000000000000000000000
OPENAI_API_KEY=sk-proj-DEMO000000000000000000000000000000000000
DATABASE_URL=postgres://postgres:hunter2@db.abcdefghijklmnop.supabase.co:5432/postgres
RESEND_API_KEY=re_DEMO0000000000000000000000000000
How an attacker exploits this
1. Attacker guesses common env file paths: /.env, /.env.local, /.env.production. One of them returns 200.
2. They now own your Stripe secret key — they can issue refunds, create charges against saved cards, and read customer PII.
3. Service role key bypasses RLS entirely — they dump every table in your database.
4. OpenAI key can be used to burn through your billing quota within minutes.
How to fix
Delete the file from the deployed artifact and add `.env*` to `.gitignore` and your build ignore list. Rotate every key that appears in the file via its provider dashboard (Supabase, Stripe, OpenAI, etc). Never through an AI chat.
criticalSecretssecret_stripe_secret_key

Stripe secret key leaked in JavaScript bundle

A `sk_live_*` Stripe secret key was found in a public JS bundle. Secret keys must NEVER touch the client. This key lets anyone who loads your site charge cards, issue refunds, and read customers.

Leaked secret in bundle
URL: https://example-saas.demo/_next/static/chunks/pages/checkout-8f2c1a.js
Line 142: const stripe = new Stripe("sk_live_51AbCdEf0000000000000000DEMOKEY", { apiVersion: "2023-10-16" });
Surrounding context: // TODO: move this to server before launch
How an attacker exploits this
1. Attacker views page source on your checkout page and searches for `sk_live_`.
2. They copy the key and run `curl https://api.stripe.com/v1/charges -u sk_live_...:`.
3. Stripe accepts it. They can now charge any saved payment method, issue refunds to their own cards, and dump your customer list.
How to fix
1. Immediately revoke this key in Stripe dashboard → Developers → API keys. 2. Move all Stripe logic to a server route or edge function that reads the key from environment variables. 3. On the client, only ever use the publishable key (`pk_live_*`). Rotate via Stripe dashboard only — never paste the key into an AI chat.
highHeadersmissing_csp

No Content-Security-Policy header

Your site does not send a Content-Security-Policy header. Any successful XSS — even reflected through a third-party dependency — can exfiltrate sessions, keystrokes, and form inputs with no browser-level mitigation.

Response headers for https://example-saas.demo/
HTTP/2 200
content-type: text/html; charset=utf-8
x-powered-by: Next.js
(no content-security-policy header present)
How an attacker exploits this
1. A dependency you use ships a compromised update (see: ua-parser-js, event-stream).
2. The malicious code runs in your customers' browsers with full access to the DOM.
3. Without CSP, it can POST session cookies and form inputs to attacker.example.
4. With a strict CSP, the outbound request is blocked and you get a violation report instead of a breach.
How to fix
Add a CSP header in `next.config.js` under `headers()`. Start with report-only mode: `Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-<random>'; object-src 'none'; base-uri 'self';`. Tighten after a week of reports.
highHeadersmissing_hsts

No HTTP Strict-Transport-Security header

HSTS is not set. A network-positioned attacker (public WiFi, compromised router, hostile ISP) can downgrade a user's first visit to HTTP and intercept credentials.

Missing header on production response
GET https://example-saas.demo/ → 200
(strict-transport-security header absent)
How an attacker exploits this
1. Victim opens their laptop at a coffee shop and types `example-saas.demo` in the address bar (no protocol).
2. Browser defaults to HTTP. Attacker on the same WiFi intercepts the request via ARP spoofing.
3. Attacker serves a proxied version of your site over HTTP and captures the login form submission.
4. HSTS would have forced the browser straight to HTTPS, making the intercept impossible.
How to fix
Return `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` on every HTTPS response. Once you're confident, submit your domain to hstspreload.org so browsers enforce HTTPS even on first visit.
highClient Storagejs_localstorage_sensitive

Access token stored in localStorage

`localStorage.setItem('access_token', ...)` appears in a shipped JS bundle. localStorage is readable by any script running on the page — one XSS and the attacker has a long-lived credential.

Pattern match in public JS
URL: https://example-saas.demo/_next/static/chunks/main-7a3b2c.js
Line 2104: localStorage.setItem("access_token", resp.data.access_token);
Line 2105: localStorage.setItem("refresh_token", resp.data.refresh_token);
How an attacker exploits this
1. Any XSS vector on any page (including a compromised npm dependency) can run `localStorage.getItem('access_token')`.
2. The attacker posts the token to their server.
3. They now have a valid session as that user until the token expires — often days or weeks.
4. httpOnly cookies would be invisible to the same XSS payload.
How to fix
Store session tokens in httpOnly, Secure, SameSite=Lax cookies issued by your server. The browser sends them automatically and JS cannot read them. If you need a refresh mechanism, do it server-side and never expose the refresh token to JS.
mediumEmaildns_no_dmarc

No DMARC record published

No DMARC record found at _dmarc.example-saas.demo. Anyone can send email that appears to come from your domain, and inbox providers have no policy telling them to reject spoofed mail.

DNS lookup result
dig TXT _dmarc.example-saas.demo → NXDOMAIN
(also checked _dmarc.www.example-saas.demo → NXDOMAIN)
How an attacker exploits this
1. Attacker sends `From: billing@example-saas.demo` to your customers asking them to "update their card" at a lookalike URL.
2. With no DMARC, Gmail/Outlook mostly deliver it — possibly to the inbox.
3. A small percentage of customers click and hand over payment details. You get the support tickets and chargebacks.
How to fix
Add a TXT record at `_dmarc.example-saas.demo` with value `v=DMARC1; p=quarantine; rua=mailto:dmarc@example-saas.demo; pct=100`. Start with `p=none` to monitor, then move to `quarantine`, then `reject` once your legitimate senders all pass.
mediumCORScors_wildcard

API responds with `Access-Control-Allow-Origin: *`

Your API returns a wildcard CORS header. Combined with any authenticated endpoint that reads cookies or bearer tokens, a malicious site the user visits can make authenticated requests and read responses.

Response from /api/me
GET https://example-saas.demo/api/me
access-control-allow-origin: *
access-control-allow-credentials: true
(combination is rejected by spec-compliant browsers but many legacy clients follow it)
How an attacker exploits this
1. Victim is logged into your app in one tab.
2. They visit attacker.example in another tab (ad, forum link, anything).
3. The malicious page does `fetch('https://example-saas.demo/api/me', { credentials: 'include' })`.
4. Because CORS is wide open, the attacker's JS reads the response — email, billing info, API keys.
How to fix
Replace the wildcard with an explicit allow-list of your own origins: `Access-Control-Allow-Origin: https://example-saas.demo`. Never reflect the request Origin unconditionally. If you need multiple origins, check the incoming Origin against a hard-coded list server-side.

2 checks passed

PASSTransporthttps_enabled

HTTPS is enforced

The site serves over TLS and HTTP requests are redirected to HTTPS. Certificate is valid and not expiring within 30 days.

Transport check
http://example-saas.demo → 308 → https://example-saas.demo/
Certificate: Let's Encrypt R3, expires 2026-07-12 (86 days)
TLS: 1.3 with X25519 key exchange
PASSEmaildns_spf

SPF record published

A valid SPF record is present. SPF alone is not enough — pair with DKIM and DMARC for real spoofing protection.

DNS lookup
dig TXT example-saas.demo
"v=spf1 include:_spf.google.com include:mailgun.org ~all"

Want to see your own?

Paste a URL. Get a real report in under 60 seconds. No signup required.

Run a real scan →