airlock-auth
v0.1.8
Published
The CI gate for Next.js auth mistakes. Fails your build when a server secret is exposed via NEXT_PUBLIC_*, and warns on mutating routes with no auth check and webhooks with no signature verification. No build required.
Maintainers
Readme
Auth Route Guard
The CI gate for Next.js auth mistakes. It scans your source and fails the
build when a server secret is exposed via NEXT_PUBLIC_* — the leak that ships
straight into the client bundle. It also warns on mutating route handlers with
no auth check and webhook routes that never verify a signature. No build, no
runtime, no database.
npx airlock-auth # scans the current project
npx airlock-auth ./apps/webWhat it flags
| Rule | Level | Catches |
|------|-------|---------|
| public_secret | fail / warn | a server secret exposed via NEXT_PUBLIC_*. Fails the build when the name can NEVER be legitimately public: service_role, any *_SECRET, private/signing/encryption/master key, a connection string for a database or broker — recognized by an engine/alias token (POSTGRES, MYSQL, MONGO, REDIS, CLICKHOUSE, KAFKA, ORACLE, TURSO, plus the stack aliases DB, DIRECT, KV that catch SUPABASE_DB_URL, Prisma's DIRECT_URL, Vercel KV_URL) beside a URL/URI/CONNECTION/DSN suffix. A descriptor in between (POSTGRES_DOCS_URL, REDIS_STATUS_URL) is a public link, not a DSN, and is not flagged. This engine list is curated, not exhaustive — an exotic engine (db2, hbase, firebird…) or an unusually-named connection string may read clean; use --fail-on warn or --allow per your stack. An LLM-provider key in either the OPENAI_KEY or the canonical OPENAI_API_KEY spelling, a Stripe SK/RK, a GitHub PAT, a GCP SA_KEY, a password. Warns when a vendor might ship the name public: a generic *_API_KEY / *_TOKEN, a bare DATABASE_URL (Firebase's is public), a bare ACCESS_KEY (Unsplash's is public). Use --fail-on warn to gate on those too. A known-public vendor key (Supabase anon, Stripe publishable, Paddle client token, Firebase, PostHog…) is exempt — never flagged. A credential trailed by a config word (SERVICE_ROLE_KEY_ROTATION, _MAX) keeps its severity; trailed by a pointer word we cannot resolve (SERVICE_ROLE_KEY_URL, CRON_SECRET_ENDPOINT) it drops to warn. |
| unauth_mutation | warn | a mutating route with no auth check — App Router route.ts and Pages Router pages/api |
| unauth_server_action | warn | a Server Action ('use server') that writes to the DB with no auth check |
| unverified_webhook | warn | a webhook route that never verifies a signature |
By default only fail findings break the build; warnings are printed for
review. Most rules here emit warn — including an unauthenticated mutation — so
without gating on them they can only ever be printed. Use --fail-on warn
(or its alias --strict) to make warnings break the build too, and the
fail-on input to do the same in the Action.
No false build breaks by design. The line between fail and warn is the
NOUN, not a vendor list: a name that can never be public (service_role, any
*_SECRET, a connection string, an LLM key) fails, while a name a vendor
might legitimately ship public (a generic *_API_KEY, a bare DATABASE_URL or
ACCESS_KEY) warns — so Alchemy, Infura, Weglot and the like never break a
real project's CI on first run. Names that are public on purpose — ANON_KEY,
PUBLISHABLE, MAPBOX,
Google Maps / Firebase client config, web-push VAPID, TURNSTILE, analytics /
site keys, and public client tokens/keys of common SDKs (Paddle CLIENT_TOKEN,
Stream, Algolia, LiveKit, Liveblocks public key, Segment write key, Sentry DSN) —
are not. A strong secret word (PRIVATE, SECRET, SIGNING, ENCRYPTION, or
an ADMIN_KEY/ADMIN_TOKEN / SERVER_KEY/SERVER_TOKEN) is always flagged, even
on such a vendor — so FIREBASE_PRIVATE_KEY, PADDLE_CLIENT_SECRET,
ALGOLIA_ADMIN_KEY, FIREBASE_ADMIN_TOKEN and FCM_SERVER_KEY are caught, while
FIREBASE_API_KEY, PADDLE_CLIENT_TOKEN and a plain ADMIN_URL/SERVER_URL are
not. (A public analytics write key like SEGMENT_WRITE_KEY is intentionally
NOT barred — those are public by design.) Read-only GET handlers are ignored,
and a webhook is judged on its signature check, not on "missing auth".
Spelling-insensitive on the suffix. Only the NEXT_PUBLIC_ prefix is matched
exactly (Next.js inlines that spelling and no other); the suffix is canonicalized
before matching, so every spelling of the same name folds to one verdict — camelCase
(serviceRoleKey), snake (SERVICE_ROLE_KEY), kebab, and even ALL-CAPS glued with
no separators at all (SERVICEROLEKEY, SECRETKEY, APIKEY) are treated the same.
A secret can't dodge the gate by changing case or dropping its underscores.
What it does not cover yet
Auth Route Guard covers the three highest-signal Next.js mistakes. These are not checked yet — review them yourself (or lean on runtime auth + the Airlock Monitor):
- Authorization correctness — it checks that an auth call is present, not that it's right. IDOR, tenant scoping and role checks are still on you.
- Auth via middleware only — a route guarded by
middleware.tsthat never references auth in its own file may warn; use--auth-fnor allow-list it. - Secrets exposed by other means — only
NEXT_PUBLIC_*names are flagged, not a secret hardcoded in client code or shipped some other way. - Read handlers (
GET) — aGETthat leaks data without auth is not flagged; only mutations are. - How a trailing word is read. The suffix is parsed as English, and silence
must be earned — the default when anything is uncertain is a warning, never a
pass. Concretely:
- A never-public credential (
SERVICE_ROLE, any*_SECRET, private/master key, a connection string, an LLM key) followed by a config word still fails:SERVICE_ROLE_KEY_MAX,STRIPE_WEBHOOK_SIGNING_SECRET_MODE,SERVICE_ROLE_KEY_ROTATIONall break the build. - A true pointer word (
URL,DOCS,HEADER,NAME,ENDPOINT) right after such a credential is ambiguous, not proof it points away — there is no safeNEXT_PUBLIC_SERVICE_ROLE_KEY_URL— so it warns rather than passes. After a name that can be public (API_KEY_HEADER) a pointer clears. Either way a pointer never breaks the build. - The known floor: a bare
PRIVATE/PASSWORD/TOKENfollowed by a config word reads as a config name and is cleared —PASSWORD_MIN_LENGTH,PRIVATE_BETA,TOKEN_REFRESH_INTERVAL, and as a costDB_PASSWORD_FLAG. Only those three words soften;CREDENTIALS,PASS,PATand every hard word (SECRET,SIGNING…) do not, soDB_CREDENTIALS_FLAGandCRON_SECRET_ROTATIONwarn rather than pass.
- A never-public credential (
- A known-public vendor key is never flagged. Supabase anon, Stripe
publishable, Paddle client token, Firebase, PostHog, Mixpanel and the rest are
allow-listed by design — a browser key that is public by the vendor's own docs
must not break a build. An unknown vendor's
*_API_KEY(Alchemy, Infura, Weglot…) warns, not fails — a generic API key may legitimately be a public browser key, so it is surfaced without breaking CI. Gate on it with--fail-on warn, or silence one with--allow NEXT_PUBLIC_THAT_NAME. - Pages Router method dispatch we cannot parse — a
pages/apihandler that writes and never mentionsreq.methodis flagged (it answers every verb). One that does consultreq.methodin a shape the matcher doesn't recognize (['POST'].includes(req.method), a dispatch table) stays silent. That is a coverage gap, not a verdict — recognized shapes are===/!==/case, with or without.toUpperCase(). - Server Action write coverage — each exported action is judged on its own
(an auth call in one action no longer clears another). The write signal covers
Supabase/knex (
.from(...).insert/update/delete), Drizzle (db.insert(...)), Prisma (prisma.x.create/update/delete) and executed raw SQL — a Supabase.rpc('...')write (ambiguous: read or write) or another ORM's.create()is not matched. The write must sit inside the exported action: a write extracted into a module-level helper declared above the exports, then called from an action, is not attributed to it (the write is judged per exported segment). Keep the DB call in the action, or gate the helper itself. - Route auth is judged per HANDLER. A
GETthat callsgetUser()next to a nakedPOSTin the sameroute.tsdoes not clear thePOST— each exported handler is sliced out and judged on its own. (An earlier release judged this per file; that was a real false negative and it is closed.) - Directories the walker skips are reported in
skipped, never silently omitted.node_modulesand dot-directories (.next,.git,.turbo,.vercel) are skipped at any depth.dist,buildandcoverageare skipped only at the project root — insideapp/, they are legitimate route segments, andapp/api/build/route.tsis a plausible privileged endpoint that must be checked. - Files over 1 MB are skipped and reported, on the assumption that they are generated or minified.
How code is read. Comments, string literals, template literals and regex
literals are neutralized before any rule runs, so a signal that only appears
inside one of them never counts as real code. If a file ends inside an
unterminated string, template or block comment, everything after the opener was
unreadable — so that file is reported as unparsable and fails the gate. It
is never reported as clean: a security gate must not answer "clean" about text it
could not read. (This detection existed but was never wired to a caller in
earlier releases, so the promise was true of the code and false of the product.)
Type annotations
are ignored rather than parsed: this is a tokenizer, not a TypeScript compiler,
which is why the package still has zero dependencies.
In CI (GitHub Actions)
- uses: mateuszingano/airlock-auth@v1
with:
dir: .
# allow: /api/health,rule:unauth_mutation
@v1tracks the latest v0.x release. Pin a specific tag (e.g.@v0.1.8) to lock the version, or runnpx --yes airlock-auth .in a step.
Allow-listing intentional cases
Some routes are public on purpose (a health check, a public read). Silence a finding by route path, env name, or rule:
airlock-auth --allow "/api/health,rule:unauth_mutation"
# or: AUTH_GUARD_ALLOW=/api/health airlock-authMatching is deliberately precise so one loose token can't hide unrelated leaks:
rule:<name>silences a whole rule (e.g.rule:unauth_mutation).- A secret (fail) needs the exact env name —
--allow keywill not silence every secret whose name contains "key"; pass the fullNEXT_PUBLIC_…_KEY. - A route (warn) matches by path — a token starting with
/(/api/health) silences that route.
Oversized files (generated/minified, > 1 MB) are skipped for bounded scan time
and listed in the report's skipped — never dropped silently.
Exit codes
0 passed · 1 an exposed secret was found · 2 usage error.
Part of ShipSealed — ship apps that don't leak. MIT licensed.
