skip to content

A Nuxt 4 app's private analytics API key appeared in its page source. Which config, module and plugin mistakes put it there, and how do you keep it server-side?

level: seniorimportance: should knowfreq 34%

answer

  1. view source, search the key
  2. public section versus top level
  3. the payload carries state
  4. a module copying all options
  5. send events through a server route

basics

~20 s

It got there through runtimeConfig.public, app.config, a module copying options into public config, or server code rendering it or storing it in payload state. Keep it a private runtimeConfig key read only by server code, which the browser calls.

solid answer

~50 s

Nuxt sends the browser three kinds of data: the client bundle, including `app.config`; `runtimeConfig.public`, embedded in every server-rendered page; and the payload, which carries `useState` values and fetch results. A private key leaks if it lands in any of them: defined under `public` or set through a `NUXT_PUBLIC_` variable, placed in `app.config.ts`, copied into public config by a module that merges all its options there, rendered in a template during SSR, or stored in `useState` or returned from a data fetch. The fix is architectural: declare `analyticsApiKey: ''` at the top level of `runtimeConfig`, set it with `NUXT_ANALYTICS_API_KEY`, read it only in a server route or a `.server` plugin, and have the browser send events to that same-origin route. Then rotate the key, because every cached or saved copy of the page still holds it.

code

ts · 11 lines
ts
// server/api/_pageview.post.ts
export default defineEventHandler(async (event) => {
  const { analyticsApiKey } = useRuntimeConfig(event) // private: server only
  const body = await readBody(event)
  await $fetch('https://collector.example/v1/events', {
    method: 'POST',
    headers: { authorization: `Bearer ${analyticsApiKey}` },
    body,
  })
  return { ok: true } // never echo the key
})

go deeper

for a junior

Recall that only runtimeConfig.public reaches the browser from runtime config and that app.config is always public, so secrets go in private runtime config only.

for a middle

Explain the four channels to the browser, bundle, public config, payload and HTML, and why useState or a fetch result can carry a secret into the payload.

for a senior

Show how you trace a leaked key, redesign the flow so the browser calls a server route, split module options into public and private, and rotate the key.

for a principal

Make leaks structurally hard: review rules for public config, module option mapping and payload contents, plus build checks that search output for secret patterns.

## What the browser receives from a Nuxt app To find a leak, list everything Nuxt sends to the browser: 1. **The client JavaScript bundle**: your components, client plugins, composables, and the contents of `app.config.ts`. 2. **The public runtime config**: `runtimeConfig.public` (plus Nuxt's internal `app` section), written into every server-rendered page as `window.__NUXT__.config`. 3. **The payload**: data serialised into the page for hydration, including `useState` values and the results of `useFetch` and `useAsyncData`. 4. **The rendered HTML** itself. A secret in any of these is public. Private runtime config keys are safe only because Nuxt leaves them out of the second item. ## The usual ways a key gets there | Mistake | Where the key ends up | |---|---| | key defined under `runtimeConfig.public`, or set via `NUXT_PUBLIC_...` | page HTML, every request | | key in `app/app.config.ts` | client bundle | | a module merges all its options, key included, into `runtimeConfig.public` | page HTML | | server code reads the key and passes it to `useState` | payload | | a `useAsyncData` handler returns an object containing the key | payload | | a template interpolates the key during SSR | rendered HTML | | the key is written into a plugin file as a literal | client bundle | The first row often starts in development. A client plugin reads `useRuntimeConfig().analyticsApiKey`, gets `undefined`, and Nuxt's development diagnostic suggests moving the key under `runtimeConfig.public`. Following that advice makes the error disappear and publishes the key. ## Keeping it server-side The durable fix changes who talks to the analytics service: - **Declare the key privately** at the top level: `runtimeConfig: { analyticsApiKey: '' }`. The empty default keeps real values out of the build. - **Set it per environment** with `NUXT_ANALYTICS_API_KEY` where the server runs. - **Read it only in server code**: a Nitro route in `server/api/`, a module's `addServerHandler` route, or a `.server` plugin. - **Let the browser call your route**, not the vendor. A `.client` plugin sends events to `/api/_pageview`; the route adds the key and forwards them. - **Never return it** from a route, a fetch handler or `useState`. For a published module this means splitting options: browser-safe ones such as endpoint and sample rate under `runtimeConfig.public.pageviewAnalytics`, the key under a private `runtimeConfig.pageviewAnalytics`. ## Verifying the fix 1. Build and serve the app, open a page, and search the page source for the key or a distinctive prefix of it. 2. Search the generated client output in `.output/public` for the same string. 3. In the browser console, inspect `window.__NUXT__.config` and confirm it holds no key. 4. Review `app.config.ts`, every `runtimeConfig.public` entry and every module's config-to-runtime mapping in code review. ## After a leak Removing the key from the code does not remove it from the world: - **Rotate it.** Any visitor, crawler or cache may have saved a copy of the page. - **Rebuild and redeploy**, and regenerate prerendered pages, which carry the config they were built with. - **Check what the key could do**, and restrict the replacement to the least it needs. ## Why it keeps happening The browser's `undefined` is a symptom that the code is running in the wrong place, not that the key is in the wrong section. Treat the development diagnostic as a question: should this code run in the browser at all? ## Making it hard to repeat One fix closes one leak; a few habits close the class of leak: - **Name secrets so they stand out.** A private key named `analyticsApiKey` next to public keys is easy to move by accident; a review rule that no key containing `key`, `secret` or `token` may appear under `public` catches most slips. - **Map module options deliberately.** A module should copy named, browser-safe fields into public config, never spread its whole options object there. - **Keep secret-using code in server files.** A route in `server/`, or a plugin whose name ends in `.server`, cannot be bundled for the browser. - **Check the build output in CI.** Searching `.output/public` and a rendered page for known secret prefixes turns a silent leak into a failed build. - **Prefer keys with narrow scope.** A key that can only write events limits the damage when a leak slips through anyway. The common thread is that Nuxt already separates server and browser correctly; leaks come from code that moves a value across that line on purpose, usually to silence an error.

  • Why is a key under runtimeConfig.public visible even though the client bundle does not contain it?
    Nuxt serialises the public runtime config into every server-rendered page as `window.__NUXT__.config`, so the browser can read it with `useRuntimeConfig()`. It is not in the JavaScript files, but it is in the HTML of every response, which is just as public.
  • Is a secret set as a default in nuxt.config safe if it stays out of runtimeConfig.public?
    It stays off the browser, but not out of the build. Nitro writes the resolved runtime config into the server output as JSON, so a real secret used as a default ends up in every copy of the build artifact. Use an empty default and supply the value with `NUXT_` variables at run time.

saying these in an interview costs you the question

  • Keys in runtimeConfig.public are only visible to server code.
  • app.config.ts is a safe place for keys because it is not an environment variable.
  • If the dev warning about a client key goes away, the configuration is correct.
  • Storing the key in useState keeps it on the server because it is set during SSR.
  • Removing the key from the code is enough; rotating it is optional.