How to Check My Website for Security Issues: 11 Free Tests
To check your website for security issues from the outside, you need a browser, a terminal and about 15 minutes. You can confirm that HTTPS is forced, read the security headers, ask for files that should never be public, and search the JavaScript your site ships for secret keys. An outside check sees only what your site serves to anyone: it can't see pages behind login, your server code, or the database rules for tables your pages never touch.
This post is for a web app you built, with Lovable, Bolt, Replit, Next.js on Vercel or Vite on Netlify. A WordPress site needs a WordPress-specific scanner, because most of its problems live in plugins and themes.
Before you start: only test sites you own
Every check below is something a browser could do. That doesn't make it fine to point at someone else's site. Run these commands only against sites you own or have written permission to test, and the Supabase check, which reads from a database, only on your own project. Replace your-app.com with your domain.
How to check my website for vulnerabilities by hand
Eleven checks, ordered roughly from quickest to most involved. Each has a command, what a bad result looks like, and the fix.
1. HTTP redirects to HTTPS, and HSTS is set
curl -sI http://your-app.com | grep -iE "^HTTP|^location"
curl -sI https://your-app.com | grep -i strict-transport-securityGood: the first command prints 301 Moved Permanently (or 308) and location: https://your-app.com/. The second prints a strict-transport-security line.
Bad: the plain http:// request answers 200 OK, so a visitor who types your address without https stays on an unencrypted page. Or the second command prints nothing. Try the www. version of your domain too; it is a separate site.
Fix. Turn on force-HTTPS at your host or DNS proxy for your custom domain. Then add Strict-Transport-Security: max-age=31536000. MDN explains why the redirect alone isn't enough: "If the browser loads an insecure http URL prior to knowing that the host is an HSTS host, the initial request is vulnerable to network attacks." HSTS tells the browser to skip that request next time.
2. Security headers: CSP, framing, nosniff, Referrer-Policy
curl -sI https://your-app.com | grep -iE "content-security-policy|x-frame-options|x-content-type-options|referrer-policy"Bad: nothing printed, which is common on a static host with default settings.
- No
Content-Security-Policy: an injected script can do anything. - No
frame-ancestors(in the CSP) and noX-Frame-Options: another site can frame yours and trick clicks. MDN says the header lets sites "avoid clickjacking attacks." - No
X-Content-Type-Options: nosniff: browsers may guess a file's type and run it as a script. - No
Referrer-Policy: the smallest gap, since browsers already default tostrict-origin-when-cross-origin, per MDN.
Fix. On Netlify, a _headers file in your publish directory (for Vite, put it in public/ so the build copies it):
/*
Content-Security-Policy: frame-ancestors 'self'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000On Vercel, the same headers go in vercel.json:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Content-Security-Policy", "value": "frame-ancestors 'self'" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
]
}
]
}That CSP only stops framing. A policy that limits scripts takes more work: ship it as Content-Security-Policy-Report-Only first, fix what it reports, then enforce it. If your host can't set headers, a CSP <meta> tag covers most of it, but not framing: MDN says frame-ancestors "is not supported in the <meta> element."
3. A public .env or .git folder
curl -s https://your-app.com/.env | head -5
curl -s https://your-app.com/.env.production | head -5
curl -s https://your-app.com/.git/config | head -5Bad: lines like STRIPE_SECRET_KEY=... from the .env files, or [core] and repositoryformatversion from .git/config. Anyone can download the first. With the second, they can often rebuild your code and its whole history.
Not bad: your own home page's HTML. Many single-page app hosts answer every unknown path with 200 and index.html, so the status code alone tells you nothing. Read the body.
Fix. Rotate every key in an exposed .env first: it has been public since it was deployed. Then take the file out of anything that gets deployed. Files in Vite's public/ folder are, per its docs, "copied to the root of the dist directory as-is." A .git folder usually ships when the whole project folder is uploaded instead of the build output. If the file was ever committed, read accidentally pushed a .env file to GitHub too.
4. Public source maps
Open your site, view the page source, and copy the name of a script, such as /assets/index-abc123.js. Then:
curl -s https://your-app.com/assets/index-abc123.js | tail -c 200
curl -s https://your-app.com/assets/index-abc123.js.map | head -c 100Bad: the script ends with //# sourceMappingURL=index-abc123.js.map, and the map starts with {"version":3. Anyone can read your original front-end code, comments included. In DevTools it shows up as your src/ folder in the Sources panel.
Fix. Vite's build.sourcemap defaults to false; 'hidden' still writes maps but drops the comment, for uploading to an error tracker. Next.js keeps productionBrowserSourceMaps off, and its docs say why: "they are disabled to prevent you leaking your source on the client."
5. Secret keys in your JavaScript bundle
The check that matters most. In Chrome DevTools, press Ctrl+Shift+F (Cmd+Option+F on a Mac) to search every loaded script for sk_live_, sb_secret_, sk-, postgres:// and service_role. Click through a few pages first: apps load some code only when needed.
From a terminal, this fetches the scripts your home page names and searches them:
SITE=https://your-app.com
for js in $(curl -s "$SITE/" | grep -oE '(src|href)="[^"]+\.js"' | cut -d'"' -f2 | grep -vE '^(https?:)?//'); do
curl -s "$SITE/${js#/}"
done > bundle.js
grep -oE "\b(sk|rk)_live_[A-Za-z0-9]{10,}|\bsb_secret_[A-Za-z0-9_-]{10,}|\bsk-[A-Za-z0-9_-]{20,}|(postgres(ql)?|mysql|mongodb(\+srv)?)://[^'\" ]+" bundle.jsBad: any output. sk_live_ and rk_live_ are Stripe live secret and restricted keys, sb_secret_ is a Supabase secret key, and sk- covers OpenAI and Anthropic (sk-ant-) keys. A database URL with a password lets anyone connect directly.
Supabase's older service_role key is a JWT, so service_role sits inside it base64-encoded, where plain search misses it. Decode each JWT's middle part:
grep -oE 'eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+' bundle.js | cut -d. -f2 | while read p; do
echo "$p==" | tr '_-' '/+' | base64 -d 2>/dev/null; echo
done"role":"anon" is the public key and is fine. "role":"service_role" is the key that skips every row level security rule. Per the Supabase API keys guide, "Policies never apply to a secret key, because service_role has the BYPASSRLS attribute." Supabase refuses a secret key sent from a browser, but that does nothing to stop someone with curl.
Why it happens. Build tools copy some environment variables into the code every visitor downloads. Vite: "Variables prefixed with VITE_ will be exposed in client-side source code after Vite bundling." Next.js says NEXT_PUBLIC_ values "will be inlined into any JavaScript sent to the browser." The rule: no secret ever gets one of those prefixes.
Fix. Rotate the key at the provider first. Then move the call that needs it into a server function. The walkthrough is in how to hide an API key in the frontend.
6. Supabase tables readable with the public key
If your app uses Supabase, your bundle contains the project URL and a public key, sb_publishable_... or a legacy anon JWT. That's by design. What the key can read depends on your Row Level Security. Find the URL and the table names your code asks for:
grep -oE 'https://[a-z0-9]+\.supabase\.co' bundle.js | sort -u
grep -oE "from\([\"'][a-z_]+[\"']\)" bundle.js | sort -uThen, on your own project only, ask for one row as a stranger would:
curl -s "https://YOUR_REF.supabase.co/rest/v1/profiles?select=*&limit=1" \
-H "apikey: YOUR_PUBLIC_KEY"Bad: a real row comes back from a table that holds user data, like [{"id":1,"full_name":"...","email":"..."}]. Anyone who opens your JavaScript can run the same request.
Good: [] (RLS is on and no policy lets a stranger see rows) or a 42501 permission error. A public table like products returning rows can be fine, if read-only is what you meant.
Fix. Enable RLS on the table and add policies that say who may see which rows: Supabase RLS disabled in public has the SQL. Why the public key is not the problem: is the Supabase anon key safe?
7. A dev server in production
curl -s https://your-app.com/ | grep -o '/@vite/client'Bad: it prints /@vite/client. Your public site is served by vite dev, not a production build. That's slower, it shows internals, and it is the exact setup Vite's file-read advisory CVE-2025-30208 applies to: "Only apps explicitly exposing the Vite dev server to the network" were affected. It happens with preview links and Replit-style hosting.
Fix. Run npm run build and deploy the build output. A Next.js server runs next start, never next dev.
8. Stripe test keys on the live site
grep -oE "\bpk_(test|live)_[A-Za-z0-9]{10,}" bundle.js | cut -c1-12 | sort -uBad: pk_test_ on your production domain. It isn't a secret: Stripe says you can include the publishable key "in front-end code or applications you distribute." But real customers can't pay in test mode. An sk_test_ in the bundle is a secret key: treat it as in check 5.
Fix. Put the pk_live_ key in your production environment and redeploy. On Vercel or Netlify, give Production and Preview different values.
9. Login cookie flags
Sign in, then open DevTools → Application → Cookies and find your session cookie. Check three columns:
- Secure unticked: the cookie can travel over plain HTTP and be stolen on an open network.
- HttpOnly unticked: any injected script can read it. Some auth libraries, including Supabase's browser client, need it readable; then a tight CSP matters more.
- SameSite empty: other sites may trigger requests that carry it.
Cookies set on the first page load show up from the terminal too:
curl -sI https://your-app.com | grep -i set-cookieFix. Set all three where the cookie is created. MDN's example: Set-Cookie: sessionId=38afes7a8; Secure; HttpOnly; SameSite=Lax.
10. CORS that trusts any website
Find an API route your app calls while signed in (DevTools → Network). Then send it a made-up origin:
curl -sI https://your-app.com/api/me -H "Origin: https://evil.example" | grep -i "^access-control"Bad: both of these come back:
access-control-allow-origin: https://evil.example
access-control-allow-credentials: trueYour server echoes whatever origin asks and allows credentials, so any website can read a signed-in visitor's data from your API.
Usually fine: access-control-allow-origin: * on its own. Browsers won't combine it with cookies: per MDN, with a credential and the wildcard, "the browser will block access to the response." Just make sure nothing private is served without a login.
Fix. Allow a fixed list of your own origins. Never copy the request's Origin into the response when credentials are allowed.
11. Email: DMARC and SPF
dig +short TXT _dmarc.your-app.com
dig +short TXT your-app.com | grep -i spfBad: no DMARC record, or one with p=none. Google's DMARC setup guide describes none as: "Take no action on the message and deliver it to the intended recipient." Mail pretending to come from you, like a fake password reset, still lands. For SPF: no record, one ending in +all, or two records (which counts as none).
This only applies to a domain you own. On your-app.vercel.app or your-app.netlify.app, those records belong to the platform.
Fix. Start with v=DMARC1; p=none; rua=mailto:you@your-app.com to see who sends as you, then move to p=quarantine and p=reject. Keep one SPF record, like v=spf1 include:<your email provider> ~all, and merge into it rather than adding a second one. Your email provider's setup page has the exact values.
Check my website security free online: four tools
If you'd rather not run commands, these do parts of it for you. None of them logs in.
Mozilla HTTP Observatory. It "performs an in-depth assessment of a site's HTTP headers and other key security configurations": the redirect to HTTPS, HSTS, CSP, framing, cookies. No account. Its FAQ says it does not test "outdated software versions, SQL injection vulnerabilities" and more, and that "the scan history for each domain is public."
Qualys SSL Labs SSL Server Test. A "free online service" that "performs a deep analysis of the configuration of any SSL web server on the public Internet": certificate, protocols, ciphers. Tick "Do not show the results on the boards" if you don't want your domain listed. On Vercel or Netlify the host manages your certificate, so this matters most on a server you set up yourself.
securityheaders.com. It grades the response headers from check 2. It is now part of Snyk, and its about page calls it "a free and easy to use tool."
Tuvio's free site scan. Paste your site's address on the scan page; no account needed. It reads your home page the way a browser does, the scripts it loads from your domain, robots.txt, the home page again over http:// and with a made-up Origin, and up to 3 source maps. It flags secret keys in your public scripts, public source maps, HTTPS and header gaps, login cookie flags, CORS that trusts any site, missing DMARC or SPF, JavaScript libraries with known security issues, a development server or build, and payments still in test mode. Every request is a plain GET, and it doesn't log in or fill in forms. It checks for a public .env or .git folder only on a site whose owner has verified it in a project. How the scanner works lists every request. The same page takes a GitHub repository. A clean report is a good sign, never a guarantee.
What no outside scan can see
Everything above reads what your site hands to strangers. Some of the worst mistakes never show up there:
- Anything behind login. An API route that returns another user's order when you change the id looks fine from outside.
- Server code. A payment webhook with no signature check, an admin route with no role check, a cron route anyone can trigger.
- Database rules for tables your pages never ask for. Check 6 tests only the tables your bundle names. A
paymentstable the frontend never touches can be wide open, and nothing on your site points to it. - Dashboard settings. Who can sign up, which Storage buckets are public, which environment variables Production and Preview get.
- Keys in your git history. A key deleted from the code is still in the commit that added it.
So check the repository too. The vibe coding security checklist covers that side by hand: RLS in your migrations, keys in git history, server-side authorization and webhooks.
How to check if my website is secure: the short list
http://redirects tohttps://, and HSTS is set.- CSP,
frame-ancestors,nosniffandReferrer-Policyheaders are present. /.envand/.git/configreturn your HTML page or a 404, never their contents.- No
.mapfiles next to your scripts. - No
sk_live_,sb_secret_,sk-, database URL orservice_roleJWT in the bundle. - Your public key returns
[]from every private table. - No
/@vite/clienton the home page. pk_live_, notpk_test_, on your real domain.- Session cookie is Secure, HttpOnly and SameSite.
- No echoed
Originwith credentials. - DMARC at
p=quarantineor stricter, and one SPF record.
Run it again after big changes: a new deploy setting can undo a fix. Passing all eleven means the outside is in order, not that the app is secure.