Let me state the conclusion first. Next.js environment variables have effectively only 2 kinds — a value with the NEXT_PUBLIC_ prefix that is "baked into the client bundle as a string at build time = published," and a value without the prefix that "exists only inside the server process." And this single line is the secret-leak boundary itself. The moment you place a service_role key or an API secret on the public side with a one-character prefix, it's distributed to the whole world.
This isn't a "environment variables are dangerous" or "Next.js is bad" story. The mechanism is clear, and used correctly it's robust. The problem is that the NEXT_PUBLIC_ prefix pushes the design judgment of "may be shown to the client" into a mere one-line key name — and that both AI and developers easily pass through this judgment with "it worked, so it's fine." This article, after re-grasping the env mechanism accurately, explains what leaks, why it leaks, and how to systematically prevent it with the server-only boundary, typed env, and secret scanning, based on real code and primary sources.
This isn't a "vertical risk that only your data model knows," like authorization or RLS — it's a story of horizontal control that can be hardened uniformly across the app. That's exactly why the correct answer is to have a mechanism stand guard, not human attention. The map of the whole app-layer security is summarized in Next.js × Supabase Application Security Complete Guide, and this article digs into the "secret hygiene" within it as one piece.
1. How Next.js env works — the dichotomy of "public" and "server-only"
The starting point of everything is the fact that Next.js handles environment variables via 2 different paths. Without understanding this behavior accurately, every countermeasure becomes a castle on sand.
1-1. No prefix = server-only (exists only inside the process)
A value referenced without a prefix like process.env.DATABASE_URL exists only inside the server (Node.js / Edge runtime) process. It can be read from a Server Component, Route Handler, Server Action, or middleware.ts, but isn't included at all in the bundle sent to the browser. Try to read process.env.DATABASE_URL in a client component and it's undefined (the official behavior. See Next.js docs).
// これらはサーバーでしか読めない。クライアントでは undefined になる
const dbUrl = process.env.DATABASE_URL; // サーバー専用
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; // サーバー専用
const resendKey = process.env.RESEND_API_KEY; // サーバー専用
This is the default, and the safe side. Do nothing, and env doesn't come out of the server.
1-2. The NEXT_PUBLIC_ prefix = inlined to the client at build time (= published)
On the other hand, a variable starting with NEXT_PUBLIC_ has its value replaced (inlined) into the code as a string literal at build time. In the JavaScript bundle delivered to the browser, the value itself is baked in as plaintext.
// ソースコード上はこう書いても…
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
// ビルド後のクライアントバンドルでは、値が直接埋め込まれる:
// const url = "https://abcdefg.supabase.co";
There are 2 decisively important points here.
- "Public" is literally publication to the whole world. Since the bundle is delivered to the browser, anyone can read the raw JavaScript in DevTools' Sources tab or network tab. A
NEXT_PUBLIC_value can be extracted as easily as "viewing" the HTML source. It's not a secret. - Because it's baked in at build time, it can't be changed later. Swapping the environment variable at runtime doesn't change the value (a rebuild is needed). That is, "a secret you accidentally placed on
NEXT_PUBLIC_" can't be taken back for the already-distributed portion even if you pull the deployed bundle. This is why rotating the key is mandatory if it leaks.
| Kind | Example | Where it exists | Visible from the client |
|---|---|---|---|
| No prefix | SUPABASE_SERVICE_ROLE_KEY | Only inside the server process | Not visible (the safe side) |
With NEXT_PUBLIC_ | NEXT_PUBLIC_SUPABASE_URL | Baked into the bundle at build time | Visible = published |
Reinterpret this dichotomy as "NEXT_PUBLIC_ = posting on a public bulletin board." Attach the prefix only to values that may be posted on the board — this is the core of the discipline.
2. What leaks — the 3 typical leak paths
env-derived secret leaks converge, almost without exception, onto the following 3 paths. Let me look at them in order, with vulnerable code and the fix as a set.
2-1. Path ①: placing a secret on NEXT_PUBLIC_
The most direct and the most fatal. An AI agent or a developer in a hurry attaches NEXT_PUBLIC_ to a secret for the reason "I want to read it from a client component."
# 危険:service_role キーに NEXT_PUBLIC_ を付けている(.env.local)
# これはビルド時にクライアントバンドルへ平文で焼き込まれ、全世界に公開される
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=<service-role-key>
NEXT_PUBLIC_RESEND_API_KEY=<resend-api-key>
NEXT_PUBLIC_STRIPE_SECRET_KEY=<stripe-secret-key>
Why does this happen? Read process.env.SUPABASE_SERVICE_ROLE_KEY (no prefix) in a client component and it's undefined. A developer or AI facing the error attaches NEXT_PUBLIC_ as the shortest path to "fix the undefined" — and it works. The demo passes. But that value is now published.
The service_role key runs with PostgreSQL's BYPASSRLS privilege and completely ignores RLS. Publishing this is the same as distributing administrator access to the entire database to the whole world. Supabase official also explicitly states "use the service_role key only on the server side" (Supabase: API keys). The separation of responsibilities of the anon key and the service_role key is itself a large subject, so it's detailed in The anon Key and service_role Key Exposure Guide.
Fix: don't attach NEXT_PUBLIC_ to a secret in the first place. If a secret "appears" to be needed on the client, the design is wrong. Place the processing that uses the secret on the server (Route Handler / Server Action), and the client receives only the result.
# 修正:秘密は接頭辞なし=サーバー専用。公開してよい値だけ NEXT_PUBLIC_
SUPABASE_SERVICE_ROLE_KEY=<service-role-key> # サーバー専用
RESEND_API_KEY=<resend-api-key> # サーバー専用
STRIPE_SECRET_KEY=<stripe-secret-key> # サーバー専用
NEXT_PUBLIC_SUPABASE_URL=https://abcdefg.supabase.co # 公開してよい
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key> # 公開前提の鍵
The doubt "may I publish a URL or anon key?" is reasonable. The anon key is "a public key designed on the premise of being distributed to the browser," and its safety is guaranteed by the RLS behind it (Supabase: API keys). On the other hand, service_role is "an admin key not premised on publication." Even for the same 'key,' whether it may be published is decided by its nature — this judgment is exactly the "human design domain" described later.
2-2. Path ②: importing server config into the client and dragging it in
This is the hardest to find and slips through review. The secret itself is correctly placed without a prefix. Yet it leaks. The cause is the import.
Next.js's bundler chain-includes the modules a client component imports into the client bundle. Accidentally import, from a client component, a config module you intended to be server-only, and even the value of the process.env.SECRET that module references gets baked into the bundle at build time.
// lib/config.ts — サーバー専用のつもりで秘密を読んでいる(が、守りは何もない)
export const config = {
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY!,
resendApiKey: process.env.RESEND_API_KEY!,
stripeSecret: process.env.STRIPE_SECRET_KEY!,
};
// app/components/pricing-table.tsx — クライアントコンポーネント
"use client";
import { config } from "@/lib/config"; // ← この一行が秘密をクライアントへ巻き込む
export function PricingTable() {
// config.publicPlans のような公開値だけ使っているつもりでも…
// バンドラは config.ts 全体をクライアントバンドルに含める。
// → serviceRoleKey / resendApiKey / stripeSecret の値まで焼き込まれて公開される
return <div>{/* ... */}</div>;
}
What's frightening is that it leaks even though the code isn't "using" the secret. Even if you don't reference config.serviceRoleKey, if at the module's top level you read process.env.SUPABASE_SERVICE_ROLE_KEY, that value can be subject to inlining. No undefined error appears either, so no one notices.
This is the most harmful form of the point touched on as "horizontal control" in the aforementioned Application Security Complete Guide. The fix is the server-only boundary itself in the next section.
2-3. Path ③: committing .env*
Classic, but still the most frequent. .env.local and .env.production have plaintext secrets lined up. Commit this to Git, and even if you delete it later, it remains permanently in the Git history. For a public repo it's instantly out; even private, it leaks to everyone in the shared range.
# 危険:秘密ファイルが追跡対象になっている
$ git status
new file: .env.local # ← service_role や API 秘密が入っている
new file: .env.production
Reliably exclude it with .gitignore, and if you've tracked it, go as far as removal from the history (and key rotation).
# .gitignore — Next.js 標準テンプレートが既定で入れている。消さないこと
.env
.env*.local
.env.production
# 例外として「秘密を含まない」例示ファイルだけは共有する
!.env.example
# 既にコミット済みかを確認する(クリーンなら何も出ない)
$ git ls-files | grep -E '^\.env'
# ← ここに .env.local 等が出たら、追跡解除+履歴除去+鍵ローテーションが必要
Erasing a committed secret from the history is a destructive operation (a history rewrite involving a force push). Always get team consensus before executing, and even on the premise of erasing it, "rotate the leaked key as something that has become unusable" is the iron rule. A secret once committed is, technically, safest treated as "leaked."
3. Prevention ①: enforce the server boundary at build time with server-only
Path ② (dragging in via import) can't be fully prevented by review or attention. You need to reject it with a mechanism. The decisive move for that is the server-only package.
server-only is a small package that does just one thing — fail the build if a module that imported it mixes into the client bundle. Place one line at the top of a module that reads secrets, and the constraint "this file is server-only" is enforced not as a type or comment but as a build error.
// lib/env.server.ts — サーバー専用。クライアントに混入したらビルド時に弾く
import "server-only"; // ← この一行が境界。クライアントから import された瞬間ビルド失敗
import { z } from "zod";
const ServerEnv = z.object({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
RESEND_API_KEY: z.string().startsWith("re_"),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
DATABASE_URL: z.string().url(),
});
// 起動時に1度だけ検証。欠けていれば即クラッシュ=fail-fast
export const serverEnv = ServerEnv.parse(process.env);
With this, a mistake like 2-2's "importing a secret config from the client" doesn't just work, the build won't pass. Even if a developer or AI writes a new component and mistakenly imports it, it's mechanically stopped at the CI build stage. This is "having a mechanism stand guard, not human attention."
# 2-2 のミスを犯すと、ビルドがこう失敗する(概念):
# You're importing a component that imports server-only.
# It only works in a Server Component, but ...
# → 秘密がバンドルに焼き込まれる前に、ビルドが落ちて事故を防ぐ
There's also a paired client-only package, with which you can put up the reverse-direction boundary of "reject if a module depending on browser APIs mixes into the server." For secret-leak countermeasures, server-only is the lead.
server-onlyis not "magic that protects secrets." What it guarantees is just the single point of "a server-only module doesn't mix into the client." It can't prevent the accident of writing a secret directly onNEXT_PUBLIC_(path ①) or committing.env(path ③). It's just one layer of the boundary, and becomes a layer only when combined with the later detection and other prevention.
4. Prevention ②: boundary-validate typed env with Zod
Environment variables are "external input." Someone mistypes a value in the deploy environment's dashboard, forgets to set a required key — these happen daily. Scattering and using env with process.env.X! (non-null assertion) is the same as trusting unvalidated external input. Validate once at the boundary, and use it from a single type-safe window thereafter.
4-1. Why "a typed env module"
process.env's type is Record<string, string | undefined>. That is, all keys are string | undefined, and the type system knows neither "does DATABASE_URL exist" nor "does it start with re_." The ! in process.env.RESEND_API_KEY! is a wish of "it should be there," not a guarantee. In production it's passed to resend.emails.send() while undefined and you notice for the first time with a cryptic error — this is a typical accident.
Validate at startup with Zod, and a missing or malformed value falls with an explicit error the moment the app starts (fail-fast). You can structurally eliminate the state of "looks like it's working but is actually broken." Type safety is both bug prevention and also detection of secret mix-ups (a value that should start with sk_ having something else in it, etc.).
4-2. Physically separate server env and client env
This is the crux of the design. Split into 2 files, attach server-only to the server env, and place only publishable values in the client env. This way, no matter who imports the "client env module" from where, there are structurally no values that would be a problem if leaked.
// lib/env.server.ts — サーバー専用の env(秘密を含む)
import "server-only";
import { z } from "zod";
const ServerEnv = z.object({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
RESEND_API_KEY: z.string().startsWith("re_"),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
DATABASE_URL: z.string().url(),
});
export const serverEnv = ServerEnv.parse(process.env);
// lib/env.client.ts — クライアントにも出てよい env(公開可能値のみ)
import { z } from "zod";
// 重要:NEXT_PUBLIC_ はビルド時にインライン化されるため、
// process.env.NEXT_PUBLIC_X を「分割代入」せず、各キーを直接参照する。
// バンドラは静的な参照しか置換できないため、これがインライン化の必須条件。
const ClientEnv = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
NEXT_PUBLIC_SITE_URL: z.string().url(),
});
export const clientEnv = ClientEnv.parse({
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
});
This 2-file configuration has 3 effects.
server-onlystops the leakage of the server env at build time. The moment you importlib/env.server.tsfrom the client, the build falls.- The client env physically has no secrets. Even if it's imported from the client by chance, it contains only values that may be published.
- The using side becomes type-safe.
serverEnv.STRIPE_SECRET_KEYis of typestring, with no worry ofundefined. Validation is done at startup.
4-3. A way of writing that doesn't break NEXT_PUBLIC_ inlining
The point touched on in 4-2's comment is an easy-to-overlook implementation trap. Because NEXT_PUBLIC_ values are statically replaced at build time, you must write them so the bundler can statically identify the replacement site.
// 動く:各キーを「丸ごと」直接参照している → バンドラが値を置換できる
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
// 壊れる:動的アクセスや分割代入は静的解析できず、置換されない(実行時 undefined)
const key = "NEXT_PUBLIC_SUPABASE_URL";
const url2 = process.env[key]; // ← 置換されない
const { NEXT_PUBLIC_SUPABASE_URL } = process.env; // ← 置換されない
Most accidents where env "is somehow undefined" on the client side are caused by this dynamic access. Write the key as a literal directly in the form process.env.NEXT_PUBLIC_X — this is the iron rule.
5. Prevention ③: the design discipline of passing only publishable values to the client
Preventions ① and ② are mechanisms, but before them is a design judgment. Each time you add a key, stop once and decide "may this value be shown to the client." Only this judgment can't be automated (the reason is Section 7).
As an auxiliary line for the judgment, the following questions take effect.
- If this value is published in plaintext to the whole world, is the damage zero? If No, you must not attach
NEXT_PUBLIC_. - Was this value "designed on the premise of being distributed to the browser"? An anon key or a public URL is Yes. An API secret, DB connection string, or signing key is No.
- Does this secret appear to be needed on the client because you've placed the processing on the client? In that case, move the processing to the server and the secret can move too.
The last question is the essence. Most situations of "a secret is needed on the client" are a design mix-up. For example, if "I want to hit an external API from the client, so a secret is needed," move the hitting processing to a Route Handler, and the client calls only its own server. The secret stays on the server.
// 悪い設計:クライアントから直接外部APIを叩こうとして秘密が要る、と錯覚する
"use client";
async function send() {
// ここで秘密が要る → NEXT_PUBLIC_ にしたくなる → 公開事故へ
await fetch("https://api.external.example/send", {
headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_API_KEY}` }, // ✗
});
}
// 良い設計:クライアントは自分のサーバーを呼ぶだけ。秘密はサーバーに留まる
"use client";
async function send() {
await fetch("/api/send", { method: "POST" }); // 秘密は一切クライアントに来ない
}
// app/api/send/route.ts — 秘密はここ(サーバー)でだけ使う
import { serverEnv } from "@/lib/env.server"; // server-only 境界の内側
export async function POST() {
await fetch("https://api.external.example/send", {
headers: { Authorization: `Bearer ${serverEnv.EXTERNAL_API_KEY}` }, // ✓
});
return Response.json({ ok: true });
}
Once this discipline of "place the processing that needs a secret on the server" sinks in, the very situation of wanting to attach NEXT_PUBLIC_ to a secret disappears. Rather than fixing it after the problem occurs, making it a structure where the problem doesn't occur is the ETC (Easy To Change) mindset.
6. Detection — mixing can be picked up mechanically by pattern
Once you've hardened prevention, verify "is it not leaking." Secret mixing, unlike the correctness of authorization, can be detected by shape (pattern) alone — that is, it's a domain where automation takes effect. Layer 3 means.
6-1. grep the build output for secret shapes
The most direct confirmation is to search the actually-built client bundle by the secret's characteristics. Whether via NEXT_PUBLIC_ or via import-dragging, a leaked secret ultimately appears in plaintext in the bundle. Look at the output directly and you can pick it up regardless of the path.
# ビルドして、クライアント向けJSに秘密の「形」が現れていないか検索する
npm run build
# service_role / Stripe secret / Resend など、鍵の接頭辞や形で grep
grep -rE 'eyJ[A-Za-z0-9_-]{20,}|sk_(live|test)_|re_[A-Za-z0-9]{16,}' .next/static \
&& echo "WARNING: 秘密の形がクライアントバンドルに見つかった" \
|| echo "OK: 既知の秘密パターンは検出されなかった"
A caveat: this can only pick up "secrets matching known shapes." A custom-format token or a secret with no characteristic shape slips through. "Not detected ≠ safe," it's merely "didn't catch on known patterns" — always be conscious of this limit.
6-2. Protect history and commits with a secret scanner
Path ③ (committing .env or hardcoding a secret into the code) is stopped with a secret scan at the pre-commit and CI stage. Many OSS scanners have this kind of detection, and the standard play is to permanently install it in a commit hook or CI as a gate that "doesn't let a commit with a secret through." GitHub also has Push Protection that detects known secrets at push time.
The point is to not make it an operation where a human checks every time. Secret mixing is not something to prevent with attention but something a machine rejects at the gate.
6-3. Pick up hardcoded secrets and NEXT_PUBLIC_ misuse with npx @aegiskit/cli scan
My published OSS Aegis implements the detection of this horizontal control. It runs with no installation and no configuration, and detects, as rules, the mis-attachment of "NEXT_PUBLIC_ × secret key" and secrets written directly in the code.
# インストール不要・設定不要でスキャン(NEXT_PUBLIC_ 誤用・ハードコード秘密を可視化)
npx @aegiskit/cli scan
Aegis also detects the data flow of tainted input → dangerous sink (the injection classes) and RLS misconfigurations of SQL migrations. But all of these are detection that looks at "shape" or "data flow," and don't make the judgment of meaning, "may it be published," as described later. This is a domain to leave to the machine and crush the misses — for details, see Aegis. How to continuously run these detections in CI is summarized in The Procedure to Flow SARIF into GitHub Actions in CI.
7. The honest scope — "detecting mixing" and "judging publishability" are different things
Let me emphasize here. Secret "mixing" can be detected mechanically, but "may this value be published" is a human design judgment. A countermeasure that leaves this line ambiguous produces dangerous complacency.
What a detection tool can do is up to the following shape judgments.
- "The name
NEXT_PUBLIC_SERVICE_ROLE_KEYis suspected of having a public prefix attached to a secret that shouldn't be published" — can warn from the name's pattern. - "A string of the form
sk_live_...is included in the client bundle" — can be reliably picked up from the shape. - "
.env.localwas committed" — can be stopped by the file name and content pattern.
But a tool structurally can't tell the following.
- Whether a certain custom token is "a public key meant to be published" or "a secret that should be hidden." It's decided by its meaning in that system, appearing neither in the name nor the shape.
- Whether
NEXT_PUBLIC_FEATURE_FLAG_Xmay be published. If it exposes internal feature info it's a problem, but that's a business confidentiality judgment and can't be derived from the code's external form. - The design mix-up itself of "is this value needed on the client, or should the processing be moved to the server."
// ツールには区別できない例:名前も形も中立な独自トークン
const token = process.env.NEXT_PUBLIC_INTERNAL_TOKEN;
// これが「公開前提の識別子」なら正しい。
// 「内部システムの認証トークン」なら重大な漏洩。
// 違いはこのシステムでの“意味”だけ。形も名前も同じ。
That is, a scanner like Aegis can stop secret mixing but can't decide "what may be published." Even if the tool is clean, that's "you didn't step on the known traps," not "the publication design is correct." This judgment is, like the "verification of configuration/secret management" that OWASP's Application Security Verification Standard (ASVS) shows, a domain where a human verifies against the requirements. Inappropriate publication of a secret is also a typical thing that OWASP Top 10's misconfiguration (Security Misconfiguration) handles. Detection is a complement, not a replacement for the design judgment.
Note that the separation of responsibilities of the key itself (what anon and service_role are allowed, where to place them) is a subject that steps even further in than this article's env boundary. The details of key handling are carved out into The anon Key and service_role Key Exposure Guide.
8. The response after a leak — rotation is "mandatory," not "optional"
Even after hardening prevention and detection, accidents can happen. What matters is to treat the key as "leaked" once a secret has come out to the public side (bundle, Git history, logs) even once. Even if you swap the bundle or erase the Git history, the already-distributed/obtained portion can't be taken back.
The principles of the response are as follows.
- First, rotate the key. Invalidate the leaked key and issue a new one. This is the top priority. "Probably no one saw it" is no grounds. A value baked into
NEXT_PUBLIC_can be extracted by anyone from the distributed bundle. - Next, plug the path. Put in the prevention of Sections 2–5 (fix the prefix, the
server-onlyboundary,.gitignore, review the design) and make it a structure where the same accident doesn't recur. - Finally, permanently install detection. Put the scan of Section 6 in CI and mechanically stop regression.
The rotation procedure differs by key type (re-issue in each service's dashboard — Supabase API keys, Stripe secrets, Resend keys, etc.). Moving at the "might have leaked" stage is the safe side. When in doubt, rotate.
9. Pre-production checklist
Whether outsourced or AI-made, before shipping to production, confirm at minimum just this. I list the viewpoint and the danger sign together.
- No secret has
NEXT_PUBLIC_attached.NEXT_PUBLIC_is only for "values posted on the public bulletin board" (service_role, API secrets, DB connection strings are without a prefix) - Modules reading secrets have
import "server-only". An import from the client is rejected at build time - env is split into 2 files for server and client, and Zod-validated at startup (the client side physically has no secrets)
-
NEXT_PUBLIC_is referenced directly in the formprocess.env.NEXT_PUBLIC_X(dynamic access / destructuring isn't inlined and becomes undefined) -
.env*is.gitignored, andgit ls-files | grep envis clean (no secret in the history either) - You grepped the build output (
.next/static) for secret shapes and confirmed no mixing - A secret scan (
npx @aegiskit/cli scan, etc.) is permanently installed in CI - Keys that might have leaked in the past are rotated (not left with "probably fine")
- Processing where a secret is "needed" on the client is moved to a Route Handler / Server Action
From the orderer's viewpoint, the most effective are the 3 questions "show me all the environment variables with NEXT_PUBLIC_ attached," "where do you use the service_role key," "aren't secret files committed?" A good developer can answer immediately.
10. How far yourself, from where a design consultation
Finally, let me draw the line honestly.
Secret-mixing countermeasures can be hardened almost entirely with automation and mechanisms. Put the server-only boundary, typed env, .gitignore, and secret scanning in CI, and the accident of "a secret coming out to the public side" is mechanically stopped. First, visualizing the current state with Aegis (free OSS, npx @aegiskit/cli scan) is the most cost-effective first step. Mixing detection isn't a domain where a human is careful every time.
On the other hand, the judgment of "what may be published," and the design of where to place processing that handles secrets (client or server), is the human domain. The confidentiality of a custom token, the exposability of a feature flag, and resolving the illusion that "a secret is needed on the client" — these can only be judged by a human who understands your system's meaning and business rules. A tool can stop mixing but doesn't prove that the publication design is correct. There's no magic that makes you completely safe.
Whereas env-boundary mixing is horizontal control, vertical risks like authorization, RLS, and tenant isolation require deeper design judgment. Where to automate and from where design is needed across the whole app layer is mapped in Next.js × Supabase Application Security Complete Guide. If you need a review of secret management, the env boundary, and the publication design of an existing app, I undertake it with a security audit. I myself have designed, in real operation, secret management and the env boundary spanning multiple backends and frontends, together with the payment-reliability layer, on the environment-sector serverless payment platform.
Frequently Asked Questions (FAQ)
Q. What should I tackle first?
A. There's an order. (1) Sweep out all env with NEXT_PUBLIC_ attached and confirm no secret is mixed in, (2) attach import "server-only" to modules reading secrets to enforce the boundary, (3) visualize mixing with npx @aegiskit/cli scan and a grep of the build output. These 3 mostly crush the most frequent accidents.
Q. Isn't it dangerous to publish the anon key or Supabase URL with NEXT_PUBLIC_?
A. The anon key is "a public key designed on the premise of being distributed to the browser," and its safety is guaranteed by the RLS behind it (Supabase: API keys). The URL is also meant to be public. What's dangerous is placing an admin key not premised on publication, like service_role, on NEXT_PUBLIC_. Even for the same 'key,' publishability is decided by its nature, so it's neither uniformly "hide keys" nor "URLs may be shown" but judged per value.
Q. process.env.NEXT_PUBLIC_X is undefined on the client.
A. Most often the cause is dynamic access (process.env[key]) or destructuring (const { NEXT_PUBLIC_X } = process.env). Because NEXT_PUBLIC_ is statically replaced at build time, it's not replaced unless you reference the key directly as a literal. Fix it to the way of writing in Section 4-3.
Q. I accidentally committed a secret from .env. Is it OK if I delete it?
A. Deleting the file is insufficient. It remains in the Git history, and if shared, it may have been obtained. Rotating the leaked key (invalidate + re-issue) is mandatory. History removal is a destructive operation, so do it with team consensus, and in any case recreate the key.
Q. If the secret scan is clean, is it safe? A. No. What a scan looks at is up to "is a secret of a known shape not mixed in." It can't pick up a custom-format secret or the design judgment of "may this value be published" (Section 7). Clean is "you didn't step on the common traps," not "the publication design is correct." Detection is a complement to review and threat modeling.
Summary: the boundary lives in "a one-character prefix"
Let me organize the key points.
- Next.js env has 2 kinds — with
NEXT_PUBLIC_it's baked into the client bundle at build time and published, without the prefix it's only inside the server process. This single line is the secret-leak boundary itself. - Leaks converge onto 3 paths — (1) attaching
NEXT_PUBLIC_to a secret, (2) importing server config from the client and dragging it in, (3) committing.env*. Especially ② is hard to find because it leaks even without "using" the secret. - Harden prevention in 3 layers — enforce the server boundary at build time with
server-only, validate typed env at startup with Zod, and pass only publishable values to the client. Reject with a mechanism, so don't rely on attention. - Detection can be mechanized — a grep of the build output, a secret scanner, and
npx @aegiskit/cli scanpick up mixing by shape. Always rotate a leaked key. - The honest scope — mixing detection can be automated, but "what may be published" is a human design judgment. A tool can stop mixing but doesn't prove the correctness of the publication design.
Building fast with AI is itself correct. Not leaking secrets from what you built fast — if you need that mechanism-building, or a review of an existing Next.js app's env boundary and secret management, feel free to consult us.