Skip to content
Chapter 82Lesson 6

Finding 5: the secret in NEXT_PUBLIC_*

This lesson documents finding 5: a Resend API key that ships to every visitor’s browser because someone named it NEXT_PUBLIC_RESEND_API_KEY.

The deliverable is findings/005-secret-next-public.md, the four template sections filled — and the load-bearing detail to get right is that the fix is structural plus rotation, not a one-line rename. This is the secrets and env-validation pass of the audit, finding 5 of 8. You’ll reproduce the fingerprint yourself: open /settings on the running app, open DevTools’ Network tab, click “Send test email,” and watch a request leave the browser carrying the key in plaintext. No code goes into the target this lesson; you write a Markdown finding.

This is a naming finding, and that’s what makes it worth slowing down for. Nothing is misconfigured in the usual sense — there’s no missing wrapper, no swallowed exception. A developer hit a build error reading a secret in a Client Component, reached for the NEXT_PUBLIC_ prefix to make the error go away, and shipped. That one prefix is the only path a variable has into the browser bundle, so naming a secret with it silently disarmed the @t3-oss/env-nextjs server/client split that would otherwise have made the leak a build-time failure. The category here combines two rules from the security baseline — secrets and env validation — because both ride the one src/env.ts schema you import as @/env; they are the same boundary read two ways.

The audit method continues the rhythm you’ve used since finding 1: grep-driven discovery, then a running-app confirmation. Three leak greps surface the secret — NEXT_PUBLIC_ in src/env.ts, process.env. anywhere outside src/env.ts, and wherever the leaked variable is actually read — plus a check that no SKIP_ENV_VALIDATION escape hatch is left open. Then you confirm it live: open /settings, open DevTools’ Network tab, click “Send test email,” and a request leaves the browser carrying the key in the Authorization header, with the key string itself searchable in the client bundle. The response status doesn’t matter — a fake key returns 401 — because the fingerprint is the key in the bundle and the request leaving the browser, not whether the mail sent.

Most of the grep hits are not defects, and naming why each is safe is part of the audit, not a step you skip. Four of the five NEXT_PUBLIC_* keys are genuinely public — the app name and URL, and the two PostHog values, where a PostHog project key is public by design — and the process.env. hits are framework exceptions like NODE_ENV and LOG_LEVEL. You record those as legitimate, not findings. An audit that flags NEXT_PUBLIC_POSTHOG_KEY as a leak is an audit nobody trusts.

Two traps to head off before you write. The fix is structural, not configurational: a rename in place does not move the key off the client, so the finding has to name the deletion-and-move-to-a-Server-Action, not a swap of one variable for another. And the key is already in production the moment this shipped, so rotation is mandatory — treating the leaked key as still safe is itself a fail. A lint rule that bans NEXT_PUBLIC_* names matching KEY, SECRET, or TOKEN is worth adding as a follow-up belt so this can’t recur, but it is never the fix. Out of scope: patching the target — the fix is a paragraph, not a diff — and the consent-gate defect that sits on the same PostHog key, which is a separate bonus finding scored later in this chapter.

findings/005-secret-next-public.md has all four template sections — Rule, Location, Consequence, Fix — populated with real prose.
tested
The finding names the rule — no secrets in NEXT_PUBLIC_*, plus the server/client env split the prefix bypassed — and cites chapter 081 lessons 6 and 7 by section.
tested
The Location section is evidence-backed — it names a discovery command (the leak greps) and the call-site file resend-test.tsx, not just an opinion.
tested
The seeded defect is still present in the target — NEXT_PUBLIC_RESEND_API_KEY in src/env.ts’s client partition, read by resend-test.tsx — because the audit is read-only.
tested
The defect is confirmed against the running app: the DevTools Network tab shows the key leaving the browser in the Authorization header.
untested
The legitimate grep hits are recorded as non-findings, each with the reason it is public-safe — the four public NEXT_PUBLIC_* keys, the framework process.env. exceptions, and no open SKIP_ENV_VALIDATION.
untested
The Consequence reads in user-visible terms — the key in the bundle, an attacker mailing from the verified domain, SPF/DKIM/DMARC-passing phishing, and domain-reputation collapse breaking real transactional mail.
untested
The Fix names the structural change — delete from the client partition and move the send to a Server Action calling sendEmail behind the existing server-only boundary — so the env split now enforces server-side residency.
untested
The Fix includes rotating the already-leaked key via the chapter 081 lesson 6 runbook in Vercel-before-provider order, with the lint rule noted as a follow-up belt rather than the fix.
untested
A severity is assigned and justified in two lines — critical, because a live key sits in every visitor’s bundle and a Client Component is already wired to fire it.
untested

Open findings/005-secret-next-public.md and write the finding against the template and the brief — run the three leak greps, reproduce the DevTools fingerprint, and name the structural-plus-rotation fix — before you open the walkthrough below.

Reference solution and walkthrough

Here is the finding as it lands in the repo. Read it once whole, then I’ll walk the decisions that aren’t obvious from the page. (The finding embeds its own grep and snippet blocks; I pull those out again below to walk them, so skim them here and study them there.)

findings/005-secret-next-public.md
# Finding 005 — Resend secret shipped to the browser via NEXT_PUBLIC_RESEND_API_KEY
**Category:** Secrets management + env validation (security baseline).
**Severity:** critical — the live Resend API key is in every visitor's JavaScript bundle and a Client Component already sends it out of the browser on click, so any user (or anyone reading the bundle) can mail from the verified domain; the key must be treated as compromised the moment this shipped.
## Rule
Secrets never reach the client bundle, and the `NEXT_PUBLIC_*` prefix is the only path to the browser — so a secret is never named `NEXT_PUBLIC_*` (chapter 081, lesson 6, Rule 2 — secrets never reach the client bundle; the watch-out names `NEXT_PUBLIC_STRIPE_SECRET_KEY` as the canonical name-contradiction bug). The structural defense behind that rule is the `@t3-oss/env-nextjs` server/client split: server-only secrets live in the `server` partition where importing them from a Client Component is a build-time error, and only genuinely public values go in `client` behind `NEXT_PUBLIC_` (chapter 081, lesson 7, invariant 2 — server-only vars in `server`, client-shipped in `client`). Declaring the key in `client` is exactly the move that disarms the split.
## Location
`src/env.ts` (the env boundary, imported as `@/env`) and the Client Component call site:
- `src/env.ts`, the `client` partition (lines 42–51): `NEXT_PUBLIC_RESEND_API_KEY: z.string().min(1)` at line 50 — a secret declared client-side, alongside the genuinely-public `NEXT_PUBLIC_APP_*` and `NEXT_PUBLIC_POSTHOG_*` values. The healthy copy is already right above it in the `server` block: `RESEND_API_KEY: z.string().min(1)` at line 24, read only by `src/lib/email.ts` behind `import 'server-only'`. So the secret exists twice — once correctly server-side, once leaked client-side.
- `src/app/(protected)/settings/resend-test.tsx`, lines 1–62: a `'use client'` component (`ResendClientTest`) that reads `env.NEXT_PUBLIC_RESEND_API_KEY` and `fetch`es `https://api.resend.com/emails` directly from the browser with `Authorization: Bearer ${env.NEXT_PUBLIC_RESEND_API_KEY}` (lines 23–36). Mounted by `src/app/(protected)/settings/page.tsx` (line 20), so it renders on `/settings`.
How it surfaced — the secret audit's three canonical leak greps, then a running-app confirmation:
```
# 1. Every NEXT_PUBLIC_* the schema declares — which ones are secret-shaped?
rg -n 'NEXT_PUBLIC_' src/env.ts
# 2. Env access that bypasses the typed boundary (the L7 invariant-1 grep).
rg -n 'process\.env\.' --glob '!src/env.ts' src
# 3. Where is the leaked var actually read?
rg -Rn 'NEXT_PUBLIC_RESEND_API_KEY' src/app
```
Grep 1 returns five `NEXT_PUBLIC_*` keys; four are verified public-safe and recorded as legitimate, not findings — `NEXT_PUBLIC_APP_NAME`, `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_POSTHOG_KEY` (a PostHog *project* key is public by design), `NEXT_PUBLIC_POSTHOG_HOST`. The fifth, `NEXT_PUBLIC_RESEND_API_KEY`, fails the "verified public-safe" test on its name alone: a Resend API key authorizes sending mail. Grep 2 returns only legitimate framework exceptions — `process.env.NODE_ENV` in `src/lib/auth.ts`, `process.env.LOG_LEVEL` in `src/lib/logger.ts`, and a comment in `src/emails/components/email-layout.tsx` — none a secret bypass, all recorded as non-findings (the schema boundary is otherwise intact, and there is no `SKIP_ENV_VALIDATION` escape hatch left open, so the L7 build-time check still fires). Grep 3 lands the call site: the Client Component reads the key and ships it.
Running-app confirmation: open `/settings`, open DevTools' Network tab, click "Send test email." A `POST https://api.resend.com/emails` leaves the browser carrying `Authorization: Bearer <the key>` in plaintext — the key is observable in the request headers, and a `view-source`/bundle search for the key string finds it inlined in the client JavaScript. The response status is irrelevant (a fake key returns 401); the fingerprint is the key in the bundle and the request leaving the browser, not whether the mail sent.
## Consequence
The Resend API key is published. Anyone who opens the app reads it straight out of the client JavaScript — no exploit required, the bundle is served to every visitor — and the key authorizes sending email from the organization's verified sending domain. An attacker mails phishing and spam *as* the company: messages that pass SPF/DKIM/DMARC because they come from the real verified domain, land in customers' inboxes looking authentic, and harvest credentials or push fraudulent invoices. The domain's sending reputation collapses under the abuse volume, so the legitimate transactional mail the product depends on — password resets, invitations, receipts — starts going to spam or bouncing for every real customer. This is live now: the key is in production, in the bundle, with a Client Component already wired to fire it from the browser, and the only thing between an attacker and the domain is that they have not looked at the bundle yet.
## Fix
The fix is structural, not a rename of the variable in place. The key belongs server-side and the send belongs behind a Server Action that holds it there:
1. **Delete `NEXT_PUBLIC_RESEND_API_KEY` from the `client` partition** of `src/env.ts` (and from `runtimeEnv`). The legitimate `RESEND_API_KEY` already lives in the `server` block — there is exactly one place the key should be, and it is already there.
2. **Move the send to a Server Action.** Replace the browser `fetch` in `resend-test.tsx` with a call to a `'use server'` action that runs `sendEmail(...)` from `src/lib/email.ts` (the existing `server-only` boundary that constructs `new Resend(env.RESEND_API_KEY)` and returns a `Result`). The client component keeps its button and status text and calls the action; the key never crosses to the browser, and the `@t3-oss/env-nextjs` split now *enforces* that — importing the `server`-partition key from the client file becomes a build-time error.
```ts
// settings/actions.ts — the key stays on the server.
'use server';
export const sendResendTest = async (): Promise<Result<{ id: string }>> =>
sendEmail({ to: 'test@example.com', subject: 'test', react: <TestEmail />, idempotencyKey: 'resend-test' });
```
3. **Rotate the leaked key — this is mandatory, not optional.** The key has already shipped to production in the client bundle, so renaming it without rotation leaves the published secret live. Run the chapter 081, lesson 6 rotation runbook in **Vercel-before-provider** order: create a fresh Resend key, set the new value in Vercel's env (with the "sensitive" flag) and redeploy *first*, then revoke the old key in the Resend dashboard — so there is no window where deployments break on a dead credential. Treat this as an event-driven rotation (suspected leak), not a calendar one.
A repo lint rule that rejects any `NEXT_PUBLIC_*` whose name matches `SECRET|TOKEN|KEY` (without a `PUBLISHABLE`-style qualifier) is worth adding so this cannot recur — but it is a **follow-up belt**, not the fix. The fix is the server-partition move plus the rotation; the lint rule only stops the next person from re-introducing it.

Now the decisions behind that page.

The location greps, and why their non-hits matter. The three commands are the secret audit’s standard sweep, and the discipline is reading every hit, not just the one that fails.

Terminal window
# 1. Every NEXT_PUBLIC_* the schema declares — which ones are secret-shaped?
rg -n 'NEXT_PUBLIC_' src/env.ts
# 2. Env access that bypasses the typed boundary.
rg -n 'process\.env\.' --glob '!src/env.ts' src
# 3. Where is the leaked var actually read?
rg -Rn 'NEXT_PUBLIC_RESEND_API_KEY' src/app

Grep 1 returns five keys, and four of them are non-findings recorded with their reason: NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_APP_URL, and the two PostHog values, where a PostHog project key is public by design. The fifth fails the public-safe test on its name alone — a Resend API key authorizes sending mail. Grep 2 is the env-schema’s own invariant grep: every config read should go through the typed env, never raw process.env, so any hit outside src/env.ts is either a deliberate framework exception or a bypass. Here it returns only NODE_ENV and LOG_LEVEL and a comment — all legitimate — and crucially there’s no SKIP_ENV_VALIDATION left on, so the build-time check still fires. Grep 3 lands the call site. Writing down why each safe hit is safe is what makes the finding trustworthy; it’s also requirement 6, the one an inexperienced auditor skips.

The secret exists twice — so the fix is a deletion, not a relocation. This is the single most important thing to see in the schema, and it’s why the fix is “delete from client,” not “move it to server.” Look at the two partitions side by side.

src/env.ts — client partition
client: {
NEXT_PUBLIC_APP_NAME: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.url(),
// PostHog (bonus #9). The project key is genuinely public; the consent gate, not
// the key, is the seeded defect — see src/app/_components/providers.tsx.
NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1),
NEXT_PUBLIC_POSTHOG_HOST: z.url(),
// SEEDED AUDIT DEFECT #5: a secret in the client partition. See the comment above.
NEXT_PUBLIC_RESEND_API_KEY: z.string().min(1),
},

The leak. NEXT_PUBLIC_RESEND_API_KEY sits at line 50 beside the four genuinely-public keys, and the NEXT_PUBLIC_ prefix is exactly what inlines it into the browser bundle.

The key the email send path actually uses — RESEND_API_KEY in the server partition, read by src/lib/email.ts — is healthy and untouched. The defect is a second copy of the same secret, declared client-side under a NEXT_PUBLIC_ name purely to satisfy the Client Component that wanted to read it. That’s why the fix is a deletion: there is already exactly one correct home for the key, and it’s already populated. (This covers requirement 8.)

The call site — the prefix is the whole bug. The leak isn’t the fetch; plenty of code calls third-party APIs. The leak is that a Client Component can see the key at all, which only the NEXT_PUBLIC_ prefix permits.

'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { env } from '@/env';
export const ResendClientTest = () => {
const [status, setStatus] = useState<string | null>(null);
const sendFromBrowser = async () => {
setStatus('sending…');
try {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
// SEEDED #5: the secret leaves the browser in plaintext.
Authorization: `Bearer ${env.NEXT_PUBLIC_RESEND_API_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({
from: 'test@example.com',
to: 'test@example.com',
subject: 'client-side test',
text: 'sent from the browser',
}),
});
setStatus(`response: ${res.status}`);
} catch {
setStatus('request failed');
}
};
// …button + status JSX omitted…
};

This is a Client Component, so everything it reads is bundle-visible. The 'use client' directive plus the import { env } from '@/env' is the shape that should make an auditor stop.

'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { env } from '@/env';
export const ResendClientTest = () => {
const [status, setStatus] = useState<string | null>(null);
const sendFromBrowser = async () => {
setStatus('sending…');
try {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
// SEEDED #5: the secret leaves the browser in plaintext.
Authorization: `Bearer ${env.NEXT_PUBLIC_RESEND_API_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({
from: 'test@example.com',
to: 'test@example.com',
subject: 'client-side test',
text: 'sent from the browser',
}),
});
setStatus(`response: ${res.status}`);
} catch {
setStatus('request failed');
}
};
// …button + status JSX omitted…
};

The secret is read straight into a header on a browser fetch. Without the NEXT_PUBLIC_ prefix this line would not compile in a client file — the env split would reject it.

'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { env } from '@/env';
export const ResendClientTest = () => {
const [status, setStatus] = useState<string | null>(null);
const sendFromBrowser = async () => {
setStatus('sending…');
try {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
// SEEDED #5: the secret leaves the browser in plaintext.
Authorization: `Bearer ${env.NEXT_PUBLIC_RESEND_API_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({
from: 'test@example.com',
to: 'test@example.com',
subject: 'client-side test',
text: 'sent from the browser',
}),
});
setStatus(`response: ${res.status}`);
} catch {
setStatus('request failed');
}
};
// …button + status JSX omitted…
};

The request goes from the user’s browser directly to Resend, which is why the key has to be in the bundle for it to work. The fix removes the need for the key here entirely by moving the send server-side.

1 / 1

The fix snippet — the structural reach, not a full diff. The audit’s rule is that fixes are paragraphs, not patches, so the finding shows only the shape: a 'use server' action that calls the existing sendEmail boundary, with the client component keeping its button and calling the action instead of fetching Resend itself.

settings/actions.ts — illustrative; the key stays on the server
'use server';
export const sendResendTest = async (): Promise<Result<{ id: string }>> =>
sendEmail({
to: 'test@example.com',
subject: 'test',
react: <TestEmail />,
idempotencyKey: 'resend-test',
});

The payoff line is the one to internalize: once you delete the client-partition copy, importing the server-partition RESEND_API_KEY from a client file becomes a build-time error. The env split stops being a label you have to remember and starts being a wall the compiler enforces. That’s the difference between a rule and a guard.

Rotation — the half inexperienced auditors leave out. The key shipped to production, so it is compromised, full stop; a rename moves the variable but leaves the already-published string live. The fix runs the rotation runbook from the secrets lesson in Vercel-before-provider order — new key, set in Vercel and redeploy first, then revoke the old key at Resend — so no deploy ever runs against a dead credential. This is an event-driven rotation (suspected leak), not a scheduled one. (Requirement 9.)

Severity — critical, justified in two lines. A live key sits in every visitor’s bundle with no exploit required to read it, and a Client Component is already wired to fire it on a button click. That combination — published secret plus a working trigger already shipped — is what makes this critical rather than high. (Requirement 10.)

For the rules this finding leans on, link rather than re-read: the no-NEXT_PUBLIC_*-for-secrets rule and the rotation runbook live in Where secrets live and how they rotate, and the server/client partition firewall and the SKIP_ENV_VALIDATION escape-hatch check live in The env schema as single source of truth. The finding cites both by section; it does not restate them.

Run the lesson’s gate:

Terminal window
pnpm test:lesson 6

The suite reads your committed finding by path and checks its observable shape: that findings/005-secret-next-public.md carries all four populated sections, that the Rule names the NEXT_PUBLIC_ secrets rule and the server/client split and cites chapter 081 lessons 6 and 7, and that the Location names a discovery grep and the resend-test.tsx call site. A source-shape probe then confirms the seeded NEXT_PUBLIC_RESEND_API_KEY is still in the env schema’s client partition and still read by the Client Component — a passing gate proves you documented the defect rather than patched it. A pass looks like this:

pnpm test:lesson 6
[32m✓[0m Lesson 6 — Finding 005 — the four template sections are populated [2m(1)[0m
[32m✓[0m Rule, Location, Consequence, and Fix each carry real prose
[32m✓[0m Lesson 6 — Finding 005 — the rule and its env split are named [2m(1)[0m
[32m✓[0m cites the NEXT_PUBLIC_ secrets rule plus chapter 081 lessons 6 and 7
[32m✓[0m Lesson 6 — Finding 005 — the location is evidence-backed [2m(1)[0m
[32m✓[0m names a discovery grep and the resend-test.tsx call site
[32m✓[0m Lesson 6 — Finding 005 — the audit stayed read-only (defect still present) [2m(2)[0m
[32m✓[0m NEXT_PUBLIC_RESEND_API_KEY is still in the env.ts client partition
[32m✓[0m resend-test.tsx is still a client component reading the leaked key
[2mTest Files[0m [32m1 passed[0m [2m(1)[0m
[2m Tests[0m [32m5 passed[0m [2m(5)[0m

The gate can confirm the finding’s shape and that the defect survived, but it can’t read your DevTools tab or judge whether your consequence reads in user terms. Confirm the rest by hand.

You reproduced the DevTools fingerprint live — on /settings with the Network tab open, “Send test email” fires a POST https://api.resend.com/emails and its Authorization header reads Bearer <key> in plaintext.
untested
The legitimate grep hits are recorded as non-findings, each with its reason — the four public NEXT_PUBLIC_* keys, the process.env. framework exceptions, and no open SKIP_ENV_VALIDATION.
untested
The Consequence reads in user-visible and domain-reputation terms — an attacker mailing from the verified domain, SPF/DKIM/DMARC-passing phishing, real transactional mail breaking — with no “could potentially” hedging.
untested
The Fix names both the structural change — delete from the client partition, move the send to a Server Action calling sendEmailand rotation of the already-leaked key.
untested
The lint rule banning secret-shaped NEXT_PUBLIC_* names is noted as a follow-up belt, not as the fix.
untested
The severity is justified in two lines — a live key in every visitor’s bundle with a Client Component already wired to fire it.
untested

With the gate green and the checklist ticked, finding 5 is documented: a secret that a single prefix walked straight into the browser, fixed by moving it behind the server boundary the compiler can enforce — and rotated, because a leaked key is compromised the instant it ships.