Methodology · Exposure Scan & Exposure Check

How the grade is calculated.

I'm asking you not to take a tool's word for whether your app is safe. It would be a strange thing to ask while refusing to show my own working. So here is all of it: what the scan looks at, what each finding costs you, what every quiz answer is worth, and where the lines between grades sit.

The grade

The scan produces one letter. It is deterministic — the same findings always produce the same grade, and the grade is decided by the worst thing found, never by an average. Averaging is how a critical finding gets buried under nine clean checks.

GradeWhenWhat it means
FAny critical finding at all.One exposed private key is enough. There is no averaging away a critical.
DTwo or more high findings.No criticals, but more than one serious gap.
CExactly one high finding.One serious gap, and otherwise reasonable.
BNo highs, at least one medium.Nothing serious open — some hardening left.
AOnly low or informational findings.Nothing the scan can see from outside is a problem.

Every check the scan runs

21 checks, grouped by what they cost you. The severity column is the same field the grader reads — this table is generated from it, not written alongside it.

critical · 7

  • Your Supabase service key is in the browser

    A key that bypasses every row-level security rule you have is sitting in a file every visitor downloads. Whoever finds it is your database admin: full read, full write, full delete, no log to tell you it happened. I treat this as an incident in progress, not a to-do.

    secret-service-role · fixed in Supabase 2 · The Two Keys

  • A Stripe secret key is in the browser

    This key can create refunds, read customer records and change prices from anywhere on the internet — it was never meant to leave your server. Anyone who has already viewed source has it, and Stripe will not know the difference between you and them.

    secret-stripe · fixed in Core 1 · Secrets & Keys

  • An AI provider key is in the browser

    Every visitor can spend your model budget until the card behind it declines. These keys get scraped within hours of appearing in a public bundle, and the bill arrives before the alert does.

    secret-ai-key · fixed in Core 1 · Secrets & Keys

  • A cloud or SaaS credential is in the browser

    A key for a cloud account, a code host, a chat workspace or an email sender is readable by anyone who opens dev tools. Depending on which service it belongs to, that is your storage, your source code, or your ability to email your own customers — handed to whoever looks.

    secret-cloud-key · fixed in Core 1 · Secrets & Keys

  • A private key is in the browser

    A PEM-formatted private key is shipping in a public file. Whatever it signs or decrypts — tokens, service-account calls, encrypted data — can now be signed or decrypted by anyone, and a private key cannot be un-published.

    secret-private-key · fixed in Core 1 · Secrets & Keys

  • A database connection string with a password is in the browser

    The full address, username and password for a database are readable in a public file. If that database accepts connections from the internet, every row in it is one command away from being copied, altered or dropped.

    secret-db-url · fixed in Core 1 · Secrets & Keys

  • A configuration file answers from your live site

    A file that belongs on the server — an environment file or part of the git directory — is being served to anyone who asks for it. Environment files hold every key the app uses; a git directory can be reassembled into your entire source history.

    config-file-reachable · fixed in Core 1 · Secrets & Keys

high · 3

  • A NEXT_PUBLIC_ variable is named like a secret

    Anything with the NEXT_PUBLIC_ prefix is copied into the JavaScript every visitor downloads — that is what the prefix is for. One of yours is named as if it holds a secret, key, token or password, and if it does, that value is already public.

    next-public-secret · fixed in Next.js 1 · The Server/Client Boundary

  • Source maps are published with your production bundle

    Your original, readable source code is downloadable next to the minified build. Comments, internal endpoints, feature flags and the exact logic behind every permission check are laid out for anyone who wants to read them.

    sourcemap-in-prod · fixed in Core 2 · What the Browser Sees

  • Your site is served over plain HTTP

    Pages and form submissions travel unencrypted, so anyone on the same network — a café, an airport, an ISP — can read and rewrite them in transit. Browsers now mark this as Not Secure, which is what your visitors see before anything else.

    no-https · fixed in Core 2 · What the Browser Sees

medium · 5

  • A Google API key is in the browser

    This is normal if it is your Firebase web config — that key is meant to be public and is only as safe as the restrictions on it. Unrestricted, it can be lifted into someone else's project and run up usage on your billing account.

    google-api-key · fixed in Core 1 · Secrets & Keys

  • No HSTS header

    Without Strict-Transport-Security, the very first request a returning visitor makes can still be plain HTTP, and that one unencrypted request is enough for someone on the network to send them somewhere else. HTTPS alone does not close this; the header does.

    no-hsts · fixed in Core 2 · What the Browser Sees

  • No Content-Security-Policy

    With no policy, any script that gets injected — through a compromised dependency, a stored comment, a bad redirect — runs with the full trust of your page. A CSP is the one control that limits the damage after something else has already gone wrong.

    no-csp · fixed in Core 2 · What the Browser Sees

  • Your pages can be framed by any other site

    Without X-Frame-Options or a frame-ancestors rule, another site can load yours invisibly inside its own page and trick a signed-in user into clicking things they cannot see. Anything a click can do on your app, a hidden frame can do too.

    no-frame-protection · fixed in Core 2 · What the Browser Sees

  • A cookie is set without Secure or HttpOnly

    A cookie without the Secure flag can be sent over plain HTTP and read on the network; a session cookie without HttpOnly can be read by any script on the page, including one that should not be there. Either way, the cookie is what the server uses to decide who you are.

    cookie-flags · fixed in Core 3 · Authn vs Authz

low · 3

  • No X-Content-Type-Options header

    Without `nosniff`, browsers are allowed to guess what a file is and treat something you served as text or an image as runnable script. It is a small door, but it is one that takes a single header to shut.

    no-nosniff · fixed in Core 2 · What the Browser Sees

  • No Referrer-Policy header

    Without a policy, the full URL of the page a visitor was on — including any ids or tokens in it — is sent to every third-party site they click through to. Your analytics, your CDN and every outbound link all get to see it.

    no-referrer-policy · fixed in Core 2 · What the Browser Sees

  • No Permissions-Policy header

    Camera, microphone, location and payment access are all available to your page — and to any script or embedded frame on it — by default. Turning off what you do not use means a compromised script cannot ask for them either.

    no-permissions-policy · fixed in Core 2 · What the Browser Sees

info · 3

  • NEXT_PUBLIC_ variables are in the bundle

    I found variables with the public prefix in your JavaScript and none of the names looks like a secret. That is how the prefix is meant to be used — this note is here so you know the boundary is real: anything that ever gets that prefix is readable by every visitor.

    next-public-present · fixed in Next.js 1 · The Server/Client Boundary

  • Supabase anon key found — expected

    The anon key is designed to be public, so seeing it here is fine. What it means is that Row Level Security is the only thing standing between a visitor and your tables — every table without a policy is readable by anyone who has this key, which is everyone.

    supabase-anon-present · fixed in Supabase 1 · RLS That Actually Restricts

  • Firebase web config found — expected

    A Firebase web app has to ship its config, so this is normal. It also means your Firestore and Storage security rules are the entire access-control layer — a rule that allows read or write to everyone is a public database.

    firebase-config-present · fixed in Firebase 1 · Rules That Rule

How the questionnaire scores

You are asked 13 questions. Two of them are routing — what you built with, and where your data lives — and they carry no weight at all; they decide which track you get. The other 11 are scored.

Each scored question has a weight, and each answer a multiplier between 0 and 1. Your score is the sum of weight × multiplier over the questions you answered, divided by the sum of those weights, times 100 — so it is a percentage of the exposure you could have had. On a multi-select question the worst option you picked sets the multiplier, but every risky option you picked still earns its own finding.

Lower is better. The number is how exposed you are, not how secure. “I don’t know” always scores as the risky answer — not to punish you, but because an unknown and a known problem need the same next action.

Core questions — everyone gets these

C1 · weight 12 · select all that apply

Where do your API keys and secrets live?

  • ×0 Server-side environment variables
  • ×0.5 Environment variables — but I'm not sure which ones reach the browser
  • ×0.5 Stored safely — but I've pasted keys into an AI chat at some point
  • ×1 In my code, or committed to git at some point
  • ×1 I don't know

C2 · weight 8

Have you ever opened your deployed app's dev tools and looked at what the browser can see — the network tab, the JS bundle?

  • ×0 Yes — I've reviewed what's exposed
  • ×0.5 Opened it once, wasn't sure what to look for
  • ×1 Never — didn't know you could

C3 · weight 12

Your app has sign-in. Who checked the difference between who someone is (authentication) and what they're allowed to touch (authorization)?

  • ×0 I did — per route and per record
  • ×0.5 The AI set it up. It looked right.
  • ×1 Nobody — or there's user data but no auth at all
  • ×1 I don't know the difference

C4 · weight 12

If a stranger signed up right now, could they see or edit another user's data by changing an ID in the URL or an API call?

  • ×0 No — I've tested this myself
  • ×0.5 Probably not, but I've never actually tried it
  • ×1 I don't know

C5 · weight 8

Do your API endpoints have rate limiting or abuse protection?

  • ×0 Yes — I set it up or verified the platform's
  • ×0.5 Some routes, or whatever the platform does by default
  • ×1 No
  • ×1 I don't know

C6 · weight 8

What personal data does your app store — emails, names, payment info, messages?

  • ×0 I know exactly what and where, and I store the minimum
  • ×0.5 I roughly know
  • ×1 I'm not sure what's being stored

C7 · weight 7

If your database vanished right now, what would happen?

  • ×0 I'd restore from backups I've actually tested
  • ×0.5 The provider probably has backups… I think
  • ×1 Everything would just be gone
  • ×1 I don't know

C8 · weight 10

If your app takes payments: where does the price actually get decided?

  • ×0 Server-side via Stripe or similar — the client never sets amounts
  • ×0.5 Checkout links; not sure how amounts are enforced
  • ×1 The frontend sends the amount
  • ×0 No payments in my app
  • ×1 I don't know

Supabase track

S1 · weight 13

Is Row Level Security (RLS) enabled on your tables?

  • ×0 Yes — with policies I wrote or reviewed
  • ×0.5 Enabled, but the AI wrote the policies and I never read them
  • ×1 No
  • ×1 What's RLS?

S2 · weight 13

Which Supabase key does your frontend use?

  • ×0 The anon key — the service key never leaves the server
  • ×0.5 I'm not sure which is which
  • ×1 The service key might be in the frontend
  • ×1 I don't know

S3 · weight 7

Your storage buckets — who can read and write your files?

  • ×0 I set policies per bucket
  • ×0.5 Whatever the defaults are — never checked
  • ×1 They're public
  • ×1 I don't know

Firebase track

F1 · weight 13

Your Firestore or Realtime Database security rules — who wrote them?

  • ×0 I wrote or reviewed them
  • ×0.5 The AI wrote them; I haven't touched them
  • ×1 They're still the test-mode defaults
  • ×1 I don't know

F2 · weight 13

Do your rules check which user owns a document — or just that someone, anyone, is signed in?

  • ×0 Per-user ownership checks on every collection
  • ×0.5 Signed-in-only, I think
  • ×1 No idea

F3 · weight 7

Cloud Storage — same question. Who wrote the rules on your buckets?

  • ×0 I did, or I reviewed them
  • ×0.5 Untouched since setup
  • ×1 Test mode / public
  • ×1 I don't know

Next.js / Vercel track

N1 · weight 13

Any environment variables starting with NEXT_PUBLIC_? (That prefix ships them to every visitor's browser.)

  • ×0 Yes — and only truly public values are in them
  • ×0.5 I have some; not sure what's in them
  • ×1 I didn't know the prefix did that
  • ×1 I don't know

N2 · weight 13

Do your /api routes and server actions check the caller's session — or do they trust whoever calls them?

  • ×0 Every route checks auth server-side
  • ×0.5 Some do
  • ×1 They trust the frontend
  • ×1 I don't know

N3 · weight 7

Your auth middleware — did you verify it protects the API itself, not just page redirects?

  • ×0 Yes, verified
  • ×0.5 I assumed the AI handled it
  • ×1 I don't know

Managed builders (Lovable · Bolt · v0 · Base44 · Replit) track

M1 · weight 11

Do you know which parts of your app run in the user's browser versus on a server?

  • ×0 Yes — I could sketch it
  • ×0.5 Roughly
  • ×1 No idea — the platform handles it

M2 · weight 11

Have you read what your platform says it secures — versus what's on you?

  • ×0 Yes, I know the split
  • ×0.5 Skimmed it
  • ×1 I didn't know there was a split

M3 · weight 11

If your platform emailed you "your database is publicly readable" — would you know where to even look?

  • ×0 Yes
  • ×0.5 Maybe, with some googling
  • ×1 No

Not sure what’s underneath track

U1 · weight 11

Could you open your app's database dashboard right now?

  • ×0 Yes — I know exactly where it is and I've logged in
  • ×0.5 I could probably find it with some digging
  • ×1 No — I'm not even sure there is one

U2 · weight 11

Do you know which parts of your app run on a server versus in the visitor's browser?

  • ×0 Yes — I could sketch it
  • ×0.5 Roughly
  • ×1 No idea

U3 · weight 11

If something went wrong tonight — leaked data, a hacked account — could you take your app offline?

  • ×0 Yes — I know exactly how
  • ×0.5 Probably, with some googling
  • ×1 No idea where I'd even start

Azure track

A1 · weight 13

What stops one signed-in user from reading another user’s rows?

  • ×0 The database itself — a row-level security policy, or access scoped per user
  • ×0.5 My app code filters by user id, and it connects as one shared database login
  • ×1 Nothing specific — the connection can read the whole table
  • ×1 I don’t know

A2 · weight 13

Does anything the browser downloads carry a storage account key or connection string?

  • ×0 No — the browser gets a short-lived SAS, or signs in with Entra ID
  • ×0.5 I’m not sure what ended up in the bundle
  • ×1 Probably — a connection string is in the app config
  • ×1 I don’t know

A3 · weight 7

Your Blob Storage containers — what is the anonymous access level?

  • ×0 No public read access — the default
  • ×0.5 Public read access for blobs only
  • ×1 Public read access for container and its blobs
  • ×1 I don’t know

Somewhere else track

O1 · weight 13

What stops one signed-in user from reading another user’s data?

  • ×0 The database enforces it — not only my app code
  • ×0.5 My app code filters it, everywhere I remembered to
  • ×1 Nothing specific — being logged in is enough
  • ×1 I don’t know

O2 · weight 13

Does anything the browser downloads carry a key that can bypass your rules?

  • ×0 No — the browser only ever gets scoped, short-lived credentials
  • ×0.5 I’m not sure what ended up in the bundle
  • ×1 Probably — there is a key in the frontend config
  • ×1 I don’t know

O3 · weight 7

Your uploaded files — who can reach them, and can the folder be listed?

  • ×0 Private, and nobody outside can list what is in there
  • ×0.5 Readable if you know the exact URL, but not listable
  • ×1 Anyone can list the folder and read everything in it
  • ×1 I don’t know

The four bands

ScoreBandWhat it means
0–20Locked DownGenuinely solid. A few refinements left.
21–45Needs AttentionMostly sound — with a few real gaps.
46–70ExposedSeveral doors are open. Most take under an hour to close.
71–100CriticalThe app is trusting strangers.

Measured versus self-checked

These are two different kinds of fact and the report labels them as such. A measured finding is something I observed on your live app. A self-checked finding is something you told me. Both are useful; only one of them is evidence.

Running the scan first settles some of the questionnaire for you. When you carry a scan into the check, these questions are answered from what was observed and are not asked again:

Everything else is still self-checked, and it always will be. Whether you have backups, whether your admin route checks who is calling it, whether the AI wrote your policies — no outside scan can see those. That is the honest limit of the scan, and it is why the check exists.

What the scan never does

What the share page publishes is narrower still: the grade and the hostname. No finding details ever appear on a public page. Full detail is on the privacy page.

Changelog

When the grading changes, the change goes here. A grade that quietly means something different this month than it did last month is not a grade.

Want to see what a finished report looks like before running one? Here's a sample — or scan your own app, free.