# Next.js Environment Variables and Secret-Leak Countermeasures — The NEXT_PUBLIC_ Trap, and the Typed env Boundary

> Environment variables with the NEXT_PUBLIC_ prefix are baked into the client bundle at build time and published. We systematically explain — in Next.js vulnerable→fixed real code — the server-only boundary that prevents secret-key mixing accidents, typed env validation with Zod, and secret-scan implementation.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Next.js, TypeScript, セキュリティ, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/nextjs-env-secret-leak-prevention-public-vars-guide
- Category: Application-layer security
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-supabase-application-security-guide

## Key points

- Next.js env has only 2 kinds — with the `NEXT_PUBLIC_` prefix it's 'inlined as a string into the client bundle at build time = published,' and without the prefix it 'exists only inside the server process.' This single line is the secret-leak boundary itself
- There are 3 typical leaks — (1) placing service_role or an API secret on `NEXT_PUBLIC_`, (2) importing a server config module from a client component and dragging the secret into the bundle, (3) committing `.env*`
- Harden prevention in 3 layers — enforce the server boundary at build time with the `server-only` package, validate a typed env module at startup with Zod, and pass only publishable values to the client
- Detection can be mechanized — grep the build output for secret shapes, a secret scanner, and `npx @aegiskit/cli scan` pick up '`NEXT_PUBLIC_` × secret key' and hardcoded secrets by shape (pattern) alone
- The honest scope: secret 'mixing' can be auto-detected by pattern, but the judgment of 'may this value be published' is human design that depends on the app's meaning. A tool can stop mixing but can't decide publishability

---

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](/blog/nextjs-supabase-application-security-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](https://nextjs.org/docs)).

```ts
// これらはサーバーでしか読めない。クライアントでは 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.

```ts
// ソースコード上はこう書いても…
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;

// ビルド後のクライアントバンドルでは、値が直接埋め込まれる：
// const url = "https://abcdefg.supabase.co";
```

There are 2 decisively important points here.

1. **"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.
2. **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."

```bash
# 危険：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](https://supabase.com/docs/guides/api/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](/blog/supabase-anon-key-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.

```bash
# 修正：秘密は接頭辞なし＝サーバー専用。公開してよい値だけ 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](https://supabase.com/docs/guides/api/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.

```ts
// 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!,
};
```

```tsx
// 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](/blog/nextjs-supabase-application-security-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.

```bash
# 危険：秘密ファイルが追跡対象になっている
$ 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).

```bash
# .gitignore — Next.js 標準テンプレートが既定で入れている。消さないこと
.env
.env*.local
.env.production
# 例外として「秘密を含まない」例示ファイルだけは共有する
!.env.example
```

```bash
# 既にコミット済みかを確認する（クリーンなら何も出ない）
$ 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.**

```ts
// 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."

```bash
# 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-only` is 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 on `NEXT_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.

```ts
// 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);
```

```ts
// 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.

1. **`server-only` stops the leakage of the server env at build time.** The moment you import `lib/env.server.ts` from the client, the build falls.
2. **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.
3. **The using side becomes type-safe.** `serverEnv.STRIPE_SECRET_KEY` is of type `string`, with no worry of `undefined`. 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.

```ts
// 動く：各キーを「丸ごと」直接参照している → バンドラが値を置換できる
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.

```tsx
// 悪い設計：クライアントから直接外部APIを叩こうとして秘密が要る、と錯覚する
"use client";
async function send() {
  // ここで秘密が要る → NEXT_PUBLIC_ にしたくなる → 公開事故へ
  await fetch("https://api.external.example/send", {
    headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_API_KEY}` }, // ✗
  });
}
```

```tsx
// 良い設計：クライアントは自分のサーバーを呼ぶだけ。秘密はサーバーに留まる
"use client";
async function send() {
  await fetch("/api/send", { method: "POST" }); // 秘密は一切クライアントに来ない
}
```

```ts
// 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.

```bash
# ビルドして、クライアント向け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.**

```bash
# インストール不要・設定不要でスキャン（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](/aegis). How to continuously run these detections in CI is summarized in [The Procedure to Flow SARIF into GitHub Actions in CI](/blog/nextjs-supabase-security-ci-sarif-github-actions-guide).

---

## 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_KEY` is 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.local` was 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_X` may 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."

```ts
// ツールには区別できない例：名前も形も中立な独自トークン
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)](https://owasp.org/www-project-application-security-verification-standard/)** shows, a domain where **a human verifies against the requirements.** Inappropriate publication of a secret is also a typical thing that **[OWASP Top 10](https://owasp.org/www-project-top-ten/)**'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](/blog/supabase-anon-key-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.

1. **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.
2. **Next, plug the path.** Put in the prevention of Sections 2–5 (fix the prefix, the `server-only` boundary, `.gitignore`, review the design) and make it a structure where the same accident doesn't recur.
3. **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 form `process.env.NEXT_PUBLIC_X`** (dynamic access / destructuring isn't inlined and becomes undefined)
- [ ] **`.env*` is `.gitignore`d, and `git ls-files | grep env` is 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](/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](/blog/nextjs-supabase-application-security-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](/aegis/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](/case-studies/payment-platform-reliability).

---

## 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](https://supabase.com/docs/guides/api/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 scan`** pick 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.

---

## References

- [OWASP Application Security Verification Standard (ASVS)](https://owasp.org/www-project-application-security-verification-standard/)
- [OWASP Top 10 (Web, including Security Misconfiguration)](https://owasp.org/www-project-top-ten/)
- [Supabase Docs — API keys (anon is meant to be public, service_role is server-only)](https://supabase.com/docs/guides/api/api-keys)
- [Next.js Docs](https://nextjs.org/docs)
