Finding 5: the secret in NEXT_PUBLIC_*
Finding 5 of 8 in the secrets and env-validation pass: a Resend API key named NEXT_PUBLIC_RESEND_API_KEY ships to every visitor’s browser.
You write a Markdown finding in findings/005-secret-next-public.md, four template sections; no code goes into the target. The detail to get right is that the fix is structural plus rotation, not a one-line rename. Reproduce the fingerprint first: on /settings, open the DevTools Network tab, click “Send test email,” and watch the request leave carrying the key in plaintext.
Your mission
Section titled “Your mission”This is a naming finding, not a misconfiguration. A developer hit a build error reading a secret in a Client Component, reached for the NEXT_PUBLIC_ prefix to silence it, and shipped. That prefix is the only path a variable has into the browser bundle, so naming a secret with it disarmed the @t3-oss/env-nextjs server/client split that would otherwise have failed the build. The category combines two baseline rules — secrets and env validation — because both ride the one src/env.ts schema, imported as @/env.
The method is grep-driven discovery, then a live confirmation. Three leak greps surface the secret — NEXT_PUBLIC_ in src/env.ts, process.env. outside src/env.ts, and wherever the leaked variable is read — plus a check that no SKIP_ENV_VALIDATION escape hatch is open. Then confirm it: on /settings with the Network tab open, click “Send test email,” and a request leaves the browser with the key in the Authorization header. The fingerprint is the key in the bundle and the request leaving, not whether the mail sent — a fake key returns 401 either way.
Most grep hits are not defects, and naming why each is safe is part of the audit. Four of the five NEXT_PUBLIC_* keys are genuinely public — the app name and URL, and the two PostHog values, where a project key is public by design — and the process.env. hits are framework exceptions like NODE_ENV and LOG_LEVEL. Record those as legitimate.
Two traps. The fix is structural: a rename in place does not move the key off the client, so name the deletion-and-move-to-a-Server-Action, not a variable swap. And the key is already in production, so rotation is mandatory — treating it as still safe is itself a fail. A lint rule banning NEXT_PUBLIC_* names matching KEY, SECRET, or TOKEN is a follow-up belt, never the fix. Out of scope: patching the target, and the consent-gate defect on the same PostHog key, a separate bonus finding.
findings/005-secret-next-public.md has all four template sections — Rule, Location, Consequence, Fix — populated with real prose.NEXT_PUBLIC_*, plus the server/client env split the prefix bypassed — and cites chapter 081 lessons 6 and 7 by section.resend-test.tsx, not just an opinion.NEXT_PUBLIC_RESEND_API_KEY in src/env.ts’s client partition, read by resend-test.tsx — because the audit is read-only.Authorization header.NEXT_PUBLIC_* keys, the framework process.env. exceptions, and no open SKIP_ENV_VALIDATION.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.Coding time
Section titled “Coding time”Write the finding in findings/005-secret-next-public.md — run the three leak greps, reproduce the DevTools fingerprint, and name the structural-plus-rotation fix — before you open the walkthrough.
Reference solution and walkthrough
Here is the finding as it lands in the repo. Read it whole, then I’ll walk the decisions behind it.
# 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.The greps, and why their non-hits matter. The discipline is reading every hit, not just the one that fails. Grep 2 is the env-schema’s invariant: every config read should go through the typed env, so any process.env outside src/env.ts is either a deliberate exception (NODE_ENV, LOG_LEVEL) or a bypass, and no open SKIP_ENV_VALIDATION means the build-time check still fires. Writing down why each safe hit is safe is what makes the finding trustworthy, and it’s the step an inexperienced auditor skips.
The secret exists twice — so the fix is a deletion, not a relocation. Look at the two partitions side by side.
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 legitimate server-side Resend key the email send path uses (lib/email.ts).// The healthy shape: this is the ONLY place the Resend key lives. Seeded defect// #5 adds a second, client-exposed copy (NEXT_PUBLIC_RESEND_API_KEY below).RESEND_API_KEY: z.string().min(1),The copy that’s already right. The legitimate key lives in the server block at line 24, read only by src/lib/email.ts behind import 'server-only' — so the fix deletes the client copy rather than relocating it.
The healthy RESEND_API_KEY in server is untouched. The defect is a second copy, declared client-side purely to satisfy the Client Component that wanted to read it. So the fix is a deletion: the key already has exactly one correct home, and it’s populated.
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 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 wouldn’t 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 browser straight to Resend, which is why the key has to be in the bundle to work. The fix removes the need for the key here by moving the send server-side.
The fix snippet — the shape, not a full diff. Audit fixes are paragraphs, not patches, so the finding shows only the shape: a 'use server' action calling the existing sendEmail boundary, with the client component calling the action instead of fetching Resend itself.
'use server';
export const sendResendTest = async (): Promise<Result<{ id: string }>> => sendEmail({ to: 'test@example.com', subject: 'test', react: <TestEmail />, idempotencyKey: 'resend-test', });The payoff: 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 remember and becomes a wall the compiler enforces.
Rotation — the half inexperienced auditors leave out. The key shipped to production, so it is compromised; a rename leaves the published string live. Run the rotation runbook in Vercel-before-provider order — new key, set in Vercel and redeploy first, then revoke at Resend — so no deploy ever runs against a dead credential. This is event-driven rotation, not scheduled.
Severity — critical. A live key sits in every visitor’s bundle with no exploit needed to read it, and a Client Component is already wired to fire it on click. A published secret plus a working trigger already shipped is what makes this critical, not high.
The rules this finding leans on live in chapter 081: the no-NEXT_PUBLIC_*-for-secrets rule and the rotation runbook in Where secrets live and how they rotate, and the server/client firewall and SKIP_ENV_VALIDATION check in The env schema as single source of truth. Cite both by section rather than restating them.
The NEXT_PUBLIC_ prefix and how it inlines a value into the browser bundle at build time — the mechanism this finding turns on.
The @t3-oss/env-nextjs server/client split that the NEXT_PUBLIC_ name bypassed — the firewall the fix puts the key back behind.
Moment of truth
Section titled “Moment of truth”Run the lesson’s gate:
pnpm test:lesson 6The suite reads your committed finding and checks its shape: all four sections populated, the Rule naming the NEXT_PUBLIC_ secrets rule and the server/client split and citing chapter 081 lessons 6 and 7, the Location naming a discovery grep and the resend-test.tsx call site. A source-shape probe then confirms NEXT_PUBLIC_RESEND_API_KEY is still in the client partition and still read by the Client Component — a pass proves you documented the defect rather than patched it:
[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)[0mThe gate checks shape and that the defect survived; it can’t see your DevTools tab or judge whether the Consequence reads in user terms. Confirm the rest by hand.
/settings with the Network tab open, “Send test email” fires a POST https://api.resend.com/emails whose Authorization header reads Bearer <key> in plaintext.NEXT_PUBLIC_* keys, the process.env. framework exceptions, and no open SKIP_ENV_VALIDATION.client partition, move the send to a Server Action calling sendEmail — and rotation of the leaked key.NEXT_PUBLIC_* names is noted as a follow-up belt, not as the fix.With the gate green and the checklist ticked, finding 5 is documented: a secret one prefix walked into the browser, fixed by moving it behind the server boundary the compiler enforces, and rotated because a leaked key is compromised the instant it ships.