# How to Check My Website for Security Issues: 11 Free Tests

URL: https://tuvio.dev/blog/check-website-security-issues
Published: 2026-10-29
By the Tuvio team

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

```bash
curl -sI http://your-app.com | grep -iE "^HTTP|^location"
curl -sI https://your-app.com | grep -i strict-transport-security
```

**Good:** 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security) 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

```bash
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 no `X-Frame-Options`: another site can frame yours and trick clicks. [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Frame-Options) 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 to `strict-origin-when-cross-origin`, per [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy).

**Fix.** On Netlify, a [`_headers` file](https://docs.netlify.com/manage/routing/headers/) in your publish directory (for Vite, put it in `public/` so the build copies it):

```text
/*
  Content-Security-Policy: frame-ancestors 'self'
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Strict-Transport-Security: max-age=31536000
```

On Vercel, the same headers go in [`vercel.json`](https://vercel.com/docs/project-configuration/vercel-json):

```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

```bash
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 -5
```

**Bad:** 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](https://vite.dev/guide/assets), "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](https://tuvio.dev/blog/accidentally-pushed-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:

```bash
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 100
```

**Bad:** 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](https://nextjs.org/docs/app/api-reference/config/next-config-js/productionBrowserSourceMaps) 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:

```bash
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.js
```

**Bad:** 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:

```bash
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](https://supabase.com/docs/guides/api/api-keys), "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](https://vite.dev/guide/env-and-mode): "Variables prefixed with `VITE_` will be exposed in client-side source code after Vite bundling." [Next.js](https://nextjs.org/docs/pages/guides/environment-variables) 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](https://tuvio.dev/blog/hide-api-key-in-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:

```bash
grep -oE 'https://[a-z0-9]+\.supabase\.co' bundle.js | sort -u
grep -oE "from\([\"'][a-z_]+[\"']\)" bundle.js | sort -u
```

Then, **on your own project only**, ask for one row as a stranger would:

```bash
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](https://tuvio.dev/blog/supabase-rls-disabled-in-public) has the SQL. Why the public key is not the problem: [is the Supabase anon key safe?](https://tuvio.dev/blog/supabase-anon-key-safe)

### 7. A dev server in production

```bash
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](https://github.com/vitejs/vite/security/advisories/GHSA-x574-m823-4x7w) 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

```bash
grep -oE "\bpk_(test|live)_[A-Za-z0-9]{10,}" bundle.js | cut -c1-12 | sort -u
```

**Bad:** `pk_test_` on your production domain. It isn't a secret: [Stripe](https://docs.stripe.com/keys) 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:

```bash
curl -sI https://your-app.com | grep -i set-cookie
```

**Fix.** Set all three where the cookie is created. [MDN's example](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie): `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:

```bash
curl -sI https://your-app.com/api/me -H "Origin: https://evil.example" | grep -i "^access-control"
```

**Bad:** both of these come back:

```text
access-control-allow-origin: https://evil.example
access-control-allow-credentials: true
```

Your 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS), 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

```bash
dig +short TXT _dmarc.your-app.com
dig +short TXT your-app.com | grep -i spf
```

**Bad:** no DMARC record, or one with `p=none`. Google's [DMARC setup guide](https://knowledge.workspace.google.com/admin/security/set-up-dmarc) 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](https://developer.mozilla.org/en-US/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](https://developer.mozilla.org/en-US/observatory/docs/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](https://www.ssllabs.com/ssltest/).** 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](https://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](https://tuvio.dev/scan); 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](https://tuvio.dev/scanner) 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 `payments` table 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](https://tuvio.dev/blog/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

1. `http://` redirects to `https://`, and HSTS is set.
2. CSP, `frame-ancestors`, `nosniff` and `Referrer-Policy` headers are present.
3. `/.env` and `/.git/config` return your HTML page or a 404, never their contents.
4. No `.map` files next to your scripts.
5. No `sk_live_`, `sb_secret_`, `sk-`, database URL or `service_role` JWT in the bundle.
6. Your public key returns `[]` from every private table.
7. No `/@vite/client` on the home page.
8. `pk_live_`, not `pk_test_`, on your real domain.
9. Session cookie is Secure, HttpOnly and SameSite.
10. No echoed `Origin` with credentials.
11. DMARC at `p=quarantine` or 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.
