Build your own security checks into your repo
Most of the checks that matter can run on every pull request with free tools: gitleaks for committed secrets, Dependabot and npm audit for vulnerable packages, a short script for secrets in your browser bundle, a daily check of your live headers and cookies, and a test that private Supabase tables stay private. Each one below is a file you copy into your repo.
Updated
Check your live site now
Free, no signup, read only. A grade and plain fixes in seconds.
What you'll set up
Each check is a file in your repo, so it runs every time without anyone remembering to. When one fails, the pull request shows a red cross and the log says what broke. You can make a failing check block merging with a branch ruleset or protection rule in your repository settings.
- Secrets: gitleaks scans every push and pull request, and a pre-commit hook stops a key before it leaves your machine.
- Dependencies: Dependabot opens update pull requests, and npm audit fails the build on high or critical advisories.
- Browser bundle: a script fails the build if a server secret ends up in the files browsers download.
- Live headers and cookies: a scheduled workflow fetches your production site and checks HSTS, CSP, nosniff, frame protection and cookie flags.
- Supabase row level security: a test asks for your private tables with the public key and fails if rows come back.
- Optional: Semgrep reads your source for risky patterns.
Everything runs on GitHub Actions. Actions is free for public repositories on standard runners, and private repositories on GitHub Free get 2,000 minutes a month. The scripts are plain Node with no dependencies, so they also run in any other CI or on your laptop.
A prompt to give your AI agent
If an agent writes your code, it can set all of this up. Paste this in, read each file it shows you, then compare with the examples below.
Add automated security checks to this repo, using GitHub Actions and free tools only. Show me each file before you commit it.
1. Secrets: add .github/workflows/gitleaks.yml that runs gitleaks/gitleaks-action@v3 on pull requests and pushes to main, with actions/checkout@v7 and fetch-depth: 0. Add a .pre-commit-config.yaml with the gitleaks hook.
2. Dependencies: add .github/dependabot.yml for npm and github-actions. Add a workflow that runs npm audit --audit-level=high (or pnpm audit --audit-level high if this repo uses pnpm).
3. Browser bundle: add scripts/check-client-secrets.mjs that fails when the value of any server only environment variable, or a known secret key format, appears in the built browser files. Run it right after the build in package.json.
4. Live site: add scripts/check-live-headers.mjs and a daily scheduled workflow that fetches my production URL and fails if Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options or frame protection is missing, or a cookie lacks Secure, SameSite or (for login cookies) HttpOnly.
5. If this app uses Supabase: add tests/rls.test.mjs that uses only the public anon or publishable key and asserts every table that should be private returns no rows. List the tables you treated as private and ask me to confirm.
Never put a secret key in any of these files. Use GitHub Actions secrets or variables for anything the workflows need.Catch secrets before they're committed
A key pasted into code is easy to push by accident, and once it's in git history, deleting the line doesn't remove it. Gitleaks scans commits for API keys, tokens and passwords. This workflow runs it on every pull request and on pushes to main:
name: gitleaks
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Only needed for repos owned by an organization, not a personal account.
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}- fetch-depth: 0 checks out the full history, so gitleaks can scan every commit in the push or pull request.
- Repositories owned by a personal account need no license. Repositories owned by an organization need a free GITLEAKS_LICENSE key from gitleaks.io, saved as an Actions secret.
- The pull-requests permission lets the action comment on the line where it found a secret.
- On public repositories GitHub also runs its own secret scanning automatically, for free. Gitleaks adds coverage for private repositories and for key formats you define yourself.
A hook on your own machine catches the secret before it ever reaches GitHub. Install the pre-commit tool (pre-commit.com), add this file to the root of your repo, then run pre-commit install once:
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaksTo check a repo's whole history once, install gitleaks (brew install gitleaks on macOS, or a binary from its releases page) and run it from the repo root. --redact keeps the secrets themselves out of the output:
gitleaks git -v --redactIf gitleaks finds a real key, rotate it in the provider's dashboard first. Removing it from the code, or even rewriting history, doesn't stop a copied key from working.
Keep your dependencies patched
Most of the code in your app comes from packages, and packages get security fixes all the time. Dependabot watches your lockfile and opens a pull request when an update is out. Add this file:
version: 2
updates:
- package-ecosystem: npm # also covers pnpm and Yarn lockfiles
directory: /
schedule:
interval: weekly
groups:
minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthlyThat file schedules routine version updates. For security fixes, open your repository's Settings, choose Advanced Security, and enable Dependabot alerts and Dependabot security updates. Security updates open a pull request when an advisory affects a package you use, without waiting for the schedule.
Dependabot fixes things after the fact. To stop a pull request from adding a known vulnerable package in the first place, audit the lockfile in CI:
name: dependency audit
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
# Fails on high or critical advisories. Reads package-lock.json, no install needed.
- run: npm audit --audit-level=high- npm audit needs a package-lock.json and exits with an error when it finds an advisory at the level you set or above.
- On pnpm, set pnpm up with pnpm/action-setup@v6 before setup-node and run pnpm audit --audit-level high instead.
- For lockfiles from other languages, OSV-Scanner reads most of them. Run osv-scanner scan source -r . locally or in CI; it exits with code 1 when it finds a vulnerability. Google also publishes a ready made workflow in google/osv-scanner-action, pinned to a full version such as v2.6.0.
Fail the build if a secret reaches the browser
Anything your front end imports ends up in JavaScript that every visitor downloads. An agent moving code between a server file and a client component can carry a key along with it, and nothing looks wrong until someone reads the bundle. This script reads your built browser files and fails if it finds the value of a server only environment variable or a known secret key format:
// Fails the build when a server secret shows up in files sent to browsers.
// Usage: node scripts/check-client-secrets.mjs [folder ...]
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
// Folders your framework writes browser code to. Pass your own if they differ.
const dirs = process.argv.slice(2);
if (dirs.length === 0) dirs.push(".next/static", "dist");
// Frameworks put variables with these prefixes into browser code on purpose.
const PUBLIC = /^(NEXT_PUBLIC_|VITE_|REACT_APP_|EXPO_PUBLIC_|PUBLIC_|NUXT_PUBLIC_)/;
const SECRET_NAME = /KEY|SECRET|TOKEN|PASSWORD|PRIVATE|SERVICE_ROLE|DATABASE_URL/;
const SHAPES = [
["Stripe secret key", /\b[rs]k_live_[A-Za-z0-9]{20,}/],
["Supabase secret key", /\bsb_secret_[A-Za-z0-9_-]{20,}/],
["Anthropic API key", /\bsk-ant-[A-Za-z0-9_-]{20,}/],
["OpenAI API key", /\bsk-proj-[A-Za-z0-9_-]{20,}/],
["AWS access key", /\bAKIA[0-9A-Z]{16}\b/],
["GitHub token", /\bgh[pousr]_[A-Za-z0-9]{36,}/],
];
// Server only values from the environment and local .env files.
const env = { ...process.env };
for (const file of [".env", ".env.local", ".env.production"]) {
if (!existsSync(file)) continue;
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/);
if (m) env[m[1]] ??= m[2].trim().replace(/^["']|["']$/g, "");
}
}
const secrets = Object.entries(env).filter(
([name, value]) => SECRET_NAME.test(name) && !PUBLIC.test(name) && value?.length >= 12,
);
function* walk(dir) {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) yield* walk(path);
else if (/\.(js|mjs|cjs|html|json|map|txt)$/.test(name)) yield path;
}
}
const found = dirs.filter((d) => existsSync(d));
if (found.length === 0) {
console.error(`None of ${dirs.join(", ")} exist. Build first, or pass your output folder.`);
process.exit(2);
}
const problems = [];
for (const dir of found) {
for (const file of walk(dir)) {
const text = readFileSync(file, "utf8");
for (const [label, re] of SHAPES) if (re.test(text)) problems.push(`${label} in ${file}`);
for (const [name, value] of secrets) {
if (text.includes(value)) problems.push(`value of ${name} in ${file}`);
}
}
}
if (problems.length > 0) {
console.error(`Server secrets in browser files:\n ${problems.join("\n ")}`);
console.error("Rotate each key that shipped, then move the code that uses it to the server.");
process.exit(1);
}
console.log(`No server secrets in ${found.join(", ")}. Values checked: ${secrets.length}.`);Run it straight after the build, in the same command. That way it sees the same environment variables your host builds with, including ones that only exist in your hosting dashboard:
{
"scripts": {
"build": "next build && node scripts/check-client-secrets.mjs"
}
}- Next.js writes browser code to .next/static and Vite writes it to dist, which are the defaults. For another framework, pass its output folder: node scripts/check-client-secrets.mjs build.
- Only point it at browser files. A folder of compiled server code would contain your secrets on purpose.
- Variables with a public prefix such as NEXT_PUBLIC_ or VITE_ are skipped, because frameworks ship those to the browser by design. That's also why a secret must never get one of those prefixes.
- It prints the variable name and the file, never the value, so the build log stays safe to read.
Check headers and cookies on your live site
Security headers live in your host's config or your middleware, so they can disappear without any build failing: a new host, a rewritten config or a framework upgrade is enough. This script fetches your production URL the way a browser does, with one GET request, and checks what comes back:
// Checks the security headers and cookie flags your live site sends.
// Usage: node scripts/check-live-headers.mjs https://your-app.com
const url = process.argv[2];
if (!url) {
console.error("Usage: node scripts/check-live-headers.mjs https://your-app.com");
process.exit(2);
}
const res = await fetch(url, { redirect: "follow" });
const h = res.headers;
const problems = [];
const hsts = h.get("strict-transport-security") ?? "";
const maxAge = Number(hsts.match(/max-age="?(\d+)/i)?.[1] ?? 0);
if (maxAge < 15552000) problems.push("Strict-Transport-Security missing or under 6 months");
const csp = h.get("content-security-policy") ?? "";
if (!csp) problems.push("Content-Security-Policy missing");
if (!/nosniff/i.test(h.get("x-content-type-options") ?? "")) {
problems.push("X-Content-Type-Options: nosniff missing");
}
if (!h.get("x-frame-options") && !/frame-ancestors/i.test(csp)) {
problems.push("No X-Frame-Options and no frame-ancestors in the CSP");
}
for (const cookie of h.getSetCookie()) {
const name = cookie.split("=")[0].trim();
const attrs = cookie.split(";").slice(1).map((a) => a.trim().toLowerCase());
const has = (flag) => attrs.some((a) => a === flag || a.startsWith(`${flag}=`));
if (!has("secure")) problems.push(`Cookie ${name} has no Secure flag`);
if (!has("samesite")) problems.push(`Cookie ${name} has no SameSite attribute`);
// Login cookies should be out of reach of page scripts. Supabase's own
// sb-...-auth-token cookies and CSRF cookies are script readable by design.
const login = /sess|auth|token|jwt/i.test(name) && !/^sb-|csrf|xsrf/i.test(name);
if (login && !has("httponly")) problems.push(`Cookie ${name} has no HttpOnly flag`);
}
if (problems.length > 0) {
console.error(`${res.url}\n ${problems.join("\n ")}`);
process.exit(1);
}
console.log(`${res.url}: headers and cookies pass`);Run it once a day, and by hand whenever you change hosting or headers. Replace the URL with your own production address:
name: live headers
on:
schedule:
- cron: "17 6 * * *" # every day at 06:17 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
headers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: node scripts/check-live-headers.mjs https://your-app.com- The HSTS minimum is six months, the same line the fix guides use. The CSP check only asks that a policy exists; how strict it is stays your call.
- It follows redirects and checks the final page. Cookies are only the ones that page sets on a plain visit, so it won't see a session cookie that appears after sign in.
- Scheduled workflows run from your default branch and can start late when GitHub is busy. In a public repository they switch off after 60 days with no activity.
- On Vercel you can also run it after each deploy: add a repository_dispatch trigger with the type vercel.deployment.success, which Vercel's GitHub integration sends.
Test that private Supabase tables stay private
Your Supabase anon or publishable key is public by design, so row level security is the only thing between that key and your data. A migration that creates a table without RLS, or a policy loosened while debugging, opens a table without any error. This test asks for each private table with the public key, exactly as any visitor could, and fails if rows come back:
// Checks that tables meant to be private return nothing to the public key.
// Run with: node --test tests/rls.test.mjs
import assert from "node:assert/strict";
import { test } from "node:test";
const url = process.env.SUPABASE_URL; // https://YOUR_PROJECT_REF.supabase.co
const key = process.env.SUPABASE_ANON_KEY; // the anon or sb_publishable_ key
// Every table a signed out visitor should not be able to read.
const PRIVATE_TABLES = ["profiles", "orders", "messages"];
for (const table of PRIVATE_TABLES) {
test(`${table} returns no rows to the public key`, async () => {
const res = await fetch(`${url}/rest/v1/${table}?select=*&limit=1`, {
headers: { apikey: key },
});
// 401 or 403 means permission denied, which is also closed.
if (res.status === 401 || res.status === 403) return;
assert.equal(res.status, 200, `${table}: unexpected status ${res.status}`);
assert.deepEqual(await res.json(), [], `${table} is readable with the public key`);
});
}name: supabase rls
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "43 6 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
rls:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: node --test tests/rls.test.mjs
env:
SUPABASE_URL: ${{ vars.SUPABASE_URL }}
SUPABASE_ANON_KEY: ${{ vars.SUPABASE_ANON_KEY }}- Save the project URL and the public key as repository variables (Settings, Secrets and variables, Actions, Variables). Both are public values, so variables are fine.
- Use only the anon or publishable key. The service_role and sb_secret_ keys skip RLS entirely, so the test would read every table and tell you nothing useful.
- An empty table also returns no rows, so the test only proves something for tables that hold data. Make sure each table on the list has at least one row.
- Run it against your own project only.
- For policies that depend on who is signed in, Supabase supports pgTAP tests in supabase/tests/database, run with supabase test db.
Optional: static analysis with Semgrep
Semgrep reads your source code and flags patterns that often lead to bugs, such as user input reaching a database query or a shell command. Semgrep Community Edition is free and runs in its own container:
name: semgrep
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
semgrep:
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
# Skip Dependabot's pull requests, as Semgrep's own sample does.
if: (github.actor != 'dependabot[bot]')
steps:
- uses: actions/checkout@v7
- run: semgrep scan --config auto --error- --config auto picks rules for the languages in your repo. To fetch them it logs in to the Semgrep Registry with your project URL. If you'd rather not, name a ruleset instead, such as --config p/default.
- --error makes the job fail when there are findings. Without it Semgrep prints them and exits 0, which is a gentler way to start.
- Expect a few false positives on the first run. Read each one before you silence it.
What these checks can't see
None of this tests your own logic: whether a signed in user can open another user's page, whether an admin route checks for an admin, or whether a price can be changed in the request. For those, sign up two test accounts and try to reach one account's data from the other.
From the outside, the quick check at pwnmyvibecode.com grades the HTTPS, headers and cookies anyone's browser sees, in a few seconds and with nothing to install. It covers the same ground as the header script above, so it works as a second opinion, or as the whole check if you'd rather not maintain a script.
Questions
Are these tools really free?
Yes, for the setups on this page. GitHub Actions is free for public repositories on standard runners, and private repositories on GitHub Free get 2,000 minutes a month. Gitleaks needs a free license key only for repositories owned by an organization. npm audit, Dependabot, OSV-Scanner and Semgrep Community Edition cost nothing.
Which check should I add first?
Secrets and dependencies. They take two files, need no configuration and catch the most common problems. Add the bundle script next if your app has server keys, the header check once your site is live, and the RLS test if you use Supabase.
What should I do when a check fails?
Read the log, which names the file, header, table or package. For a leaked key, rotate it before anything else. For a package, merge the Dependabot update or bump it yourself. For headers and cookies, the fix guides on this site have config for each host.
Do I need GitHub for this?
No. The two scripts and the RLS test are plain Node files that run anywhere Node 22 or later is installed. Gitleaks, OSV-Scanner and Semgrep are command line tools too, so the same commands work in GitLab CI, Bitbucket Pipelines or a local pre-push hook.