Methodology · Exposure Scan & Exposure Check
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 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.
| Grade | When | What it means |
|---|---|---|
| F | Any critical finding at all. | One exposed private key is enough. There is no averaging away a critical. |
| D | Two or more high findings. | No criticals, but more than one serious gap. |
| C | Exactly one high finding. | One serious gap, and otherwise reasonable. |
| B | No highs, at least one medium. | Nothing serious open — some hardening left. |
| A | Only low or informational findings. | Nothing the scan can see from outside is a problem. |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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 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 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.
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.
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.
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.
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.
C1 · weight 12 · select all that apply
Where do your API keys and secrets live?
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?
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)?
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?
C5 · weight 8
Do your API endpoints have rate limiting or abuse protection?
C6 · weight 8
What personal data does your app store — emails, names, payment info, messages?
C7 · weight 7
If your database vanished right now, what would happen?
C8 · weight 10
If your app takes payments: where does the price actually get decided?
S1 · weight 13
Is Row Level Security (RLS) enabled on your tables?
S2 · weight 13
Which Supabase key does your frontend use?
S3 · weight 7
Your storage buckets — who can read and write your files?
F1 · weight 13
Your Firestore or Realtime Database security rules — who wrote them?
F2 · weight 13
Do your rules check which user owns a document — or just that someone, anyone, is signed in?
F3 · weight 7
Cloud Storage — same question. Who wrote the rules on your buckets?
N1 · weight 13
Any environment variables starting with NEXT_PUBLIC_? (That prefix ships them to every visitor's browser.)
N2 · weight 13
Do your /api routes and server actions check the caller's session — or do they trust whoever calls them?
N3 · weight 7
Your auth middleware — did you verify it protects the API itself, not just page redirects?
M1 · weight 11
Do you know which parts of your app run in the user's browser versus on a server?
M2 · weight 11
Have you read what your platform says it secures — versus what's on you?
M3 · weight 11
If your platform emailed you "your database is publicly readable" — would you know where to even look?
U1 · weight 11
Could you open your app's database dashboard right now?
U2 · weight 11
Do you know which parts of your app run on a server versus in the visitor's browser?
U3 · weight 11
If something went wrong tonight — leaked data, a hacked account — could you take your app offline?
A1 · weight 13
What stops one signed-in user from reading another user’s rows?
A2 · weight 13
Does anything the browser downloads carry a storage account key or connection string?
A3 · weight 7
Your Blob Storage containers — what is the anonymous access level?
O1 · weight 13
What stops one signed-in user from reading another user’s data?
O2 · weight 13
Does anything the browser downloads carry a key that can bypass your rules?
O3 · weight 7
Your uploaded files — who can reach them, and can the folder be listed?
| Score | Band | What it means |
|---|---|---|
| 0–20 | Locked Down | Genuinely solid. A few refinements left. |
| 21–45 | Needs Attention | Mostly sound — with a few real gaps. |
| 46–70 | Exposed | Several doors are open. Most take under an hour to close. |
| 71–100 | Critical | The app is trusting strangers. |
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:
C2 — Have you looked at what the browser actually sees?
The scan just did. Always settled after a scan.
S2 — Which Supabase key does your frontend use?
Settled when a Supabase fingerprint is found — risky if a privileged key is reachable, safe if only the publishable one is.
N1 — What is in your NEXT_PUBLIC_ variables?
Settled when Next.js is detected — risky if a public variable is named like a secret, partial if there are public variables to review, safe if there are none.
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 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.
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.
v2.0 · September 2026
Added the passive Exposure Scan; Supabase RLS-by-default lowered nothing yet
v1.0 · August 2026
The Exposure Check: 13 questions, 11 of them scored, four bands, findings written per answer.
Want to see what a finished report looks like before running one? Here's a sample — or scan your own app, free.