# How to keep test and live API keys from crossing environments

> Put the environment in the key itself and check it at process start, so a staging deployment holding a production credential refuses to run.

Source: https://voxgig.com/howto/keep-test-and-live-keys-apart

- Audience: platform-team
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-09-06
- Published: 2026-09-06

## Short answer

Give every key a prefix that names its environment, then assert at process start that the prefix matches the environment the process believes it is in. A mismatch throws before any call goes out. The same prefix lets a secret scanner recognize the key in a repository, and lets a log line identify a key without printing it.

---
## You will need

Node 22 or later, and control over how your keys are issued, or an API that already prefixes them.
[Stripe's key format](https://docs.stripe.com/keys) is the widely copied example, and the pattern
below is the same idea applied to keys you issue yourself.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A prefix the code checks](https://docs.stripe.com/keys) | You issue the keys, or the provider already prefixes them | Nothing catches a key with the right prefix used in the wrong account, so it is one layer of two | The provider issues opaque keys you cannot classify |
| [Separate credential stores](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) | Deployments already read secrets from a store rather than from files | A store to run, and a path that has to be right in every deployment manifest | A single small service reading environment variables |
| [Scanning before commit](https://github.com/gitleaks/gitleaks) | Keys keep arriving in commits and you want them stopped locally | A hook every contributor has to install, and it sees only what git sees | The leak path is a log or an error tracker rather than a commit |
| [Provider-side scanning](https://docs.github.com/en/code-security/secret-scanning/introduction/about-secret-scanning) | The code is on a forge that supports it and the provider participates | It reports after the push, so it shortens exposure rather than preventing it | The repository is not on a participating forge |

These are layers rather than alternatives. The prefix check is the one that runs on every start of
every process, which is why it goes first. Scanning catches the key that reached a repository, and
a credential store stops the copy-and-paste that put it there. A team running all three still needs
the prefix check, because the store hands out whatever it was told to hold.

## Put the environment in the key

The prefix is a contract between the issuer and every process that reads a key.

```ts title="guard.mjs"
const PREFIXES = { test: 'sk_test_', live: 'sk_live_' }

export function environmentOf(key) {
  for (const [env, prefix] of Object.entries(PREFIXES)) {
    if (key.startsWith(prefix)) return env
  }
  return null
}

export function assertKeyMatchesEnvironment(key, environment) {
  const keyEnv = environmentOf(key)
  if (keyEnv === null) {
    throw new Error(`key does not carry an environment prefix, expected one of ${Object.values(PREFIXES).join(', ')}`)
  }
  if (keyEnv !== environment) {
    throw new Error(`refusing to use a ${keyEnv} key while running in ${environment}`)
  }
  return keyEnv
}
```

An unrecognized key is refused rather than allowed. That decision is the one that gets softened
under deadline, and softening it removes the guarantee. A key with no prefix could belong to either
environment. Treating it as the safe one is a guess dressed as a policy.

The prefix has to be applied where the key is minted, which means the issuing endpoint rather than
the client. Generate the secret with
[a cryptographic random source](https://nodejs.org/api/crypto.html#cryptorandombytessize-callback)
and prepend the prefix for the environment the request came from. Store a hash of the whole string
rather than the string itself. The prefix then travels with the key
through every environment variable, deployment manifest and support ticket it passes through, and no
later step has to remember what it was for.

Call the assertion where the configuration is read, not where the first request is made. A process
that starts, passes a health check and then fails on its first customer request is harder to notice
than one that refuses to start at all.

## Make the key safe to log

A key you cannot mention is a key nobody can debug. The prefix gives you a way to say which
credential a process is holding without printing the secret.

```ts title="guard.mjs"
/** Redacts a key for a log line: the prefix identifies it, the secret does not leak. */
export function redact(key) {
  const env = environmentOf(key)
  return env === null ? 'unrecognized' : `${PREFIXES[env]}...${key.slice(-4)}`
}
```

Four trailing characters are enough to tell two keys apart in a log and far too few to use. Take
them from the end rather than the start, because the start is the prefix and every key in an
environment shares it. Print
that on startup beside the environment name, and the mismatch becomes visible in the deployment log
even where the guard is not running.

## Check it worked

Run the three cases a deployment can actually be in.

```bash
node demo.mjs
```

```text output
test       sk_test_...p7dc  accepted
test       sk_live_...p7dc  refused: refusing to use a live key while running in test
production unrecognized     refused: key does not carry an environment prefix, expected one of sk_test_, sk_live_
```

The second line is the failure this page exists for, and the third is the one that would otherwise
be waved through. Both refuse before any request leaves the process.

```bash
node --test guard.test.mjs
```

```text output
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 107.58153
```

## When it goes wrong

The guard passes and the key is still wrong. A prefix names an environment, and it says nothing
about which account inside that environment the key belongs to. Two live keys from two customer
accounts both pass. Where that distinction matters, have the process fetch its own identity from
the API at startup and compare it with the account it expects.

The second failure is a prefix that drifts. A provider that adds a third environment, or renames
one, breaks a hardcoded map, and the failure mode is the safe one only if unrecognized keys are
refused. Read the prefix list from the same configuration that issues the keys, and a new
environment arrives as data rather than as a deploy.

## When not to do this

Do not use a prefix as the only separation between environments. It catches the key in the wrong
process, and it does nothing about a test process pointed at a production database. Separate
accounts, separate stores and separate networks are what actually keep the two apart.

Do not put the environment in the key if your provider issues keys you cannot change. Deriving a
prefix from a key by hashing it, or by remembering the first eight characters, gives a check that
fails the first time a key is rotated.

Do not log the redacted form at debug level and the full key anywhere else. One code path that
prints the whole secret undoes the redaction everywhere, and the place it usually appears is an
error object serialized into a tracker.

Do not treat a passing guard as permission to keep production credentials in developer environments.
The guard stops a process from using the wrong key. Nothing stops a person from reading it.

## Related how-tos

- [Add a bearer token to fetch without a client library](/howto/bearer-token-fetch-wrapper)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

## Last verified

Verified 2026-09-06 against Node 22.22.2. Both output blocks are what the preceding command printed.
The key values are fictitious and follow the documented Stripe prefix format.