# How API keys and tokens leak out of application code

> Leaked keys usually escape through a log line, an error body, a URL or a commit. Four leaks to look for, plus a grep check on the build that can fail.

Published 2026-10-30 · https://aiskills402.com/blog/how-secrets-leak-from-code

A secret leaks from code in four common ways, and none of them needs an attacker to break anything. The code writes the secret to a log, sends it back to a client, puts it in a URL, or sits in a file that gets committed. Each of these is a normal-looking line written by someone who was thinking about something else, such as debugging a failed call. This guide takes the four in turn, adds a fifth small trap in how secrets are compared, and closes with a test of the built files that is able to fail.

## Leak one: the secret is logged

Logging is where secrets go when somebody wants to see why a request failed. The usual forms are printing the key itself, printing a whole object with the key inside it, dumping the whole environment, logging full request headers (the authorization header is among them), and writing an error message that repeats the configuration it was built from.

The weakness has its own entry in the Common Weakness Enumeration: CWE-532 describes a product writing sensitive information to a log file, and its advice is plain: do not write secrets into log files, remove debug logging before production, and protect the files from unauthorized reading ([CWE-532](https://cwe.mitre.org/data/definitions/532.html)). The OWASP Logging Cheat Sheet has a list of data that should not be recorded directly, and it names access tokens, authentication passwords and session identifiers (the latter with a suggestion to store a hash instead, if session events must be tracked) ([OWASP Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html)). The Secrets Management Cheat Sheet states the same requirement from the secrets side: a secret must never be logged, which calls for masking or encryption so plaintext does not reach a log ([OWASP Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html)).

The code smell to search for is a log call that receives a whole object. This line looks harmless:

```js
console.log("starting", config); // config.apiKey is in there
```

Whoever can read the log can read the key, and logs usually have more readers than the environment variable ever did. Reporting that a key exists, or how long it is, confirms configuration safely. Printing the value does not.

## Leak two: the secret is shipped to the browser

Two routes lead here. The first is a variable whose name carries the marker a framework uses to publish it to client code. Next.js states that variables without `NEXT_PUBLIC_` are available only in Node.js, and that any variable carrying `NEXT_PUBLIC_` is inlined at build time into the JavaScript delivered to the browser, with each reference replaced by the literal value ([Next.js environment variables](https://nextjs.org/docs/app/guides/environment-variables)). Vite behaves the same way for the `VITE_` prefix: such variables are exposed in client-side source after bundling, and Vite says plainly that these variables should not hold sensitive information such as API keys, because their values are bundled at build time ([Vite env variables](https://vite.dev/guide/env-and-mode)). Both pages describe a design, not a bug. The leak happens when a person adds the prefix to make an error go away and the value was a secret.

The second route is data. A server component reads a secret or a record that contains one, and hands the object to a client component as a property. Anything given to a client component ends up in what the browser receives. That sentence is general framework knowledge rather than a quotation from the pages above, so verify it in the framework docs for your version. The same reasoning applies to an error response: a handler that returns a caught exception as its response body sends along whatever the message contains, which can include a connection string or a request URL with a key in it.

The repair for both is to hold the secret on the server and let the browser call a route that attaches it. A key built for public use, such as a publishable payment key or a CAPTCHA site key, is fine under a public prefix. The test is not the variable name but whether the value gives access to something.

## Leak three: the secret is in the URL

Putting a key in a query string (`?api_key=...`) is convenient and wrong. CWE-598 covers the weakness of sending sensitive information in a query string, lists API keys, access tokens, passwords and session identifiers among the examples, and recommends putting such data in the request body or headers instead, which may mean avoiding GET requests for these calls ([CWE-598](https://cwe.mitre.org/data/definitions/598.html)).

The reason is where URLs travel. Web servers and proxies commonly record them in access logs, browsers keep them in history, and a URL can be copied into a chat or a ticket. The Referer header is another route, though that detail is from general web knowledge and not from the pages cited here. A key in a header or a POST body does not have these side effects, and most APIs offer both. If an API only takes the key in the URL, treat the key as lower-trust and rotate it more often.

## Leak four: the secret is committed

A literal key in source, an `.env` file added to the repository, an example config holding a real value, and a test fixture with a pasted token all end up in version control. The OWASP Secrets Management Cheat Sheet opens with the observation that many organizations have secrets hardcoded in plaintext in source code and scattered through configuration files ([OWASP Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html)).

Deleting the line later does not undo it, because the value remains in the history. GitHub's secret scanning reflects this: it reads the whole Git history on every branch, and it rescans when new secret types are added, which means an old commit can raise an alert long after the file changed ([GitHub secret scanning](https://docs.github.com/en/code-security/secret-scanning/introduction/about-secret-scanning)). The practical rule is that a committed secret is a burned secret: replace it, and treat the old value as public. Vite's docs show the safer layout for local files, where `.env.local` is loaded in all cases and is ignored by git ([Vite env files](https://vite.dev/guide/env-and-mode)). Example files should hold empty values or an obviously fake word, never a copy of the real thing.

## A fifth trap: comparing secrets with ===

When code checks a submitted token against the stored one, a plain `===` can stop at the first different character, and the time the check takes can tell an attacker how much of a guess was right. The Node.js documentation provides `crypto.timingSafeEqual(a, b)`, which compares the bytes with a constant-time algorithm and is meant for values such as HMAC digests and authentication cookies. Two details matter: both inputs must have the same byte length or the call throws, and the docs add that using it does not make the surrounding code timing-safe ([Node.js crypto](https://nodejs.org/api/crypto.html)). A common way to satisfy the length rule is to hash both sides first and compare the digests. This is a leak through behavior, not through output, so it is easy to miss in review.

## A check that can fail: grep the built bundle

Reading code finds a leak only when you notice it. A build can be tested directly. Set the variable to a fake value that is easy to search for, build the project the way you ship it, and search the output the browser will receive.

```bash
SECRET_KEY=zz-fake-secret-for-leak-test npm run build
grep -r "zz-fake-secret-for-leak-test" .next/static dist build 2>/dev/null
```

No match is the pass. Output folders differ by framework (`.next/static` for Next.js, `dist` for a typical Vite project), so search the one that holds browser files.

A check that has never failed does not prove anything, so make it fail once. Rename the variable to carry the public prefix (`NEXT_PUBLIC_` or `VITE_`), rebuild with the same fake value, and run the grep again. It must find the value. If it does not, you searched the wrong folder or built the wrong way, and the earlier "no match" meant nothing. Also search the log output of a normal run for the fake value, since logging is the leak the bundle check cannot see, and search your repository before the first commit with the same string.

## When this does not apply

These four leaks cover app code, scripts and app config. They do not cover how a secret is stored outside the code, who has access to a repository, how secrets are rotated or how a log service is configured, and each of those deserves its own review against the OWASP material cited above. The grep check catches a secret that is inlined into browser files at build time; it will not catch one that a server sends in a response at run time, so test error paths and server-rendered pages separately. A fake value only tests the paths that use it, so make sure the build actually exercises the code that reads the variable. Framework behavior can change between versions, and we did not test the examples here on a live project; the snippets illustrate the documented behavior. For a neighbouring case where a credential is handled in a verification step, see [our notes on mistakes in JWT verification](/blog/jwt-verification-mistakes).

## A reviewer for the code

Our [secret-handling-review](https://aiskills402.com/skills/secret-handling-review) skill reads source you paste in, whether an app module, a helper script or a settings file, and lists just the spots where a secret lands in logs, in URLs, in committed files or in the browser. Each finding names the spot, says who could read the value there and what to write instead, and the answer never repeats the secret itself. Harmless patterns, such as reporting that a key exists, a publishable key under a public prefix and a server-side environment lookup, stay off the list, and a clean result gets one fixed sentence. It works on text you paste, so it does not scan a repository's history or a built bundle; the grep check above covers the bundle.
