Published: · Updated: · by kodewright
API Key Management: Best Practices for 2026
Generate, prefix, hash, scope and rotate API keys, tested in Node 22: which key formats gitleaks catches, and why SHA-256 beats bcrypt here.

API key management comes down to five things: generate at least 128 bits from a CSPRNG, give the key a prefix that secret scanners can recognize, store only a hash of it, scope it to what it needs, and rotate it with an overlap window. Then keep it out of git, frontend bundles and logs, which is where most keys actually leak.
I'll be upfront about where this comes from. I haven't built an API-key system for an API of my own, and I haven't had a leaked-key incident. What's here is the standard design, checked in this session: the prefix advice tested against gitleaks 8.28.0, and the storage advice with timings from Node 22.
What makes an API key hard to guess
An API key is a bearer token: whoever holds it is trusted, with no second factor and no prompt. An attacker can guess it, derive it from a weak generator, or steal it. Guessing is a math problem:
H = L × log₂(R)
H is entropy in bits, L the number of characters, and R the size of the character set. A 32-character hex key (R = 16) carries 32 × 4 = 128 bits. With base64url each character carries 6 bits, but 16 random bytes encode to 22 characters that hold exactly 128 (the last character only carries 2). Count the random bytes that went in, not the characters that came out.
Suppose the key leaks as a SHA-256 digest and the attacker has one RTX 4090 at about 22 billion SHA-256 guesses per second (hashcat 6.2.6). Average time, half the keyspace:
| Key | Random bytes | Entropy | Combinations | Average offline crack (1× RTX 4090) |
|---|---|---|---|---|
| 16 hex chars | 8 | 64 bits | 1.8 × 10¹⁹ | ~13 years |
| 32 hex or 22 base64url chars | 16 | 128 bits | 3.4 × 10³⁸ | ~2.5 × 10²⁰ years |
| 64 hex or 43 base64url chars | 32 | 256 bits | 1.2 × 10⁷⁷ | ~8 × 10⁵⁸ years |
A hundred rented cards turn the 64-bit row into about seven weeks, so 64 bits is out. At 128 bits the same fleet needs more than 10¹⁸ years, and theft is the only attack left. I'd issue 32 bytes by default, since the extra length costs nothing.
Generate keys from a CSPRNG
All of that assumes the bits are real. Math.random() doesn't qualify: in V8 it's xorshift128+, and four outputs are enough to rebuild its state, which I tested in CSPRNG explained. In Node use crypto.randomBytes(32), in Python secrets.token_urlsafe(32) or secrets.choice over an alphabet. Both read from the operating system's CSPRNG.
For a one-off key, the Secret Key Generator does the same with crypto.getRandomValues() in your browser tab, and the key never leaves the page. Its alphanumeric format at 256 bits gives 43 characters, which matters for the next section.
Give keys a prefix that scanners recognize
A prefix such as sk_live_ tells you the key's type and environment at a glance, and it lets secret scanners find leaked keys with few false positives. GitHub moved its tokens to this design in 2021: ghp_ for personal access tokens, an underscore because it isn't a Base64 character, and a CRC32 checksum in the last 6 characters, so a scanner can reject random lookalikes offline (GitHub's write-up).
To see what that buys in practice, I generated keys in several shapes with crypto.randomBytes and ran gitleaks dir (8.28.0, default rules) over them:
| What was in the file | Detected? | Rule |
|---|---|---|
const k = "<64 hex chars>" | No | |
API_KEY=<64 hex chars> in a .env | Yes | generic-api-key |
const config = { token: "<43 base64url chars>" } | Yes | generic-api-key |
ghp_ + 36 alphanumeric | Yes | github-pat |
sk_live_ + 43 alphanumeric (5 files) | Yes, 5 of 5 | stripe-access-token |
sk_live_ + 43 base64url (6 files) | No, 0 of 6 | |
skt_ + 43 base64url, own prefix | No | |
| same, with a 5-line custom rule | Yes | my-api-key |
Two things I didn't expect. A bare hex string with no telling variable name goes through unnoticed, which means detection on unprefixed keys depends on what the developer happened to call the variable. And copying a well-known prefix only helps if the body matches the alphabet scanners expect: each of the base64url bodies I checked contained a - or _, and the Stripe rule stopped matching.
My takeaway: use your own prefix, an alphanumeric body (the Secret Key Generator's alphanumeric output, or base62 in code), and ship a scanner rule for it. For gitleaks, the rule is short:
[extend]
useDefault = true
[[rules]]
id = "my-api-key"
description = "My API key (skt_ prefix)"
regex = '''\bskt_[A-Za-z0-9_-]{43}\b'''
keywords = ["skt_"]
The first 4 to 8 characters after the prefix also work as a non-secret lookup ID: users can tell keys apart in a dashboard, and you can grep logs for them without holding the secret. Keep the random part at 128 bits or more on top of whatever you display.
Store a hash of the key, never the key
The server should never be able to print a key back. Show it to the user once, then store only its digest. A fast hash is enough here, and that's the point most guides skip past: bcrypt and Argon2 exist to slow down guessing of low-entropy human passwords, and there's nothing to guess in 256 random bits. Use HMAC-SHA-256 with a server-side secret instead of plain SHA-256 if you want a stolen table to be useless even for checking whether a known key is in it.
The full issue-and-verify flow fits in a few lines of Node:
import crypto from 'node:crypto'
function issue() {
const key = `skt_${base62(43)}` // 43 chars × log₂(62) ≈ 256 bits
const hash = crypto.createHash('sha256').update(key).digest()
return { key, row: { prefix: key.slice(0, 12), hash } } // show key once, save row
}
function verify(incoming, row) { // row looked up by prefix
const h = crypto.createHash('sha256').update(incoming).digest()
return crypto.timingSafeEqual(h, row.hash)
}
On Node 22.23 that verification took 1.05 µs per check, averaged over 200,000 runs. The same key checked with bcryptjs 3.0.3 took 69 ms at cost 10 and 275 ms at cost 12. bcryptjs is pure JavaScript and slower than the native binding, but even a native bcrypt adds tens of milliseconds to every authenticated request, and anyone who can send you requests can make your CPU do that work. For API keys a slow KDF buys no security and costs you latency and an easy denial of service.
timingSafeEqual compares the two digests without exiting early on the first different byte. You can cross-check the digests your code produces with the Hash Generator while building the flow.
Scope every key to the minimum it needs
A key that can do everything is the one worth stealing. Give each key read or write scopes per resource, so the analytics widget's key can't delete users. Keep test and live keys apart by prefix and by data, so a leaked test key costs nothing. Bind server-to-server keys to known egress IPs where your infrastructure allows it, and put a rate limit on each key so a stolen one can't burn through your quota before monitoring notices.
Many narrow keys are better than one master key for the same reason IAM roles are: one leak exposes one capability.
Rotate on a schedule, revoke on suspicion
The longer a key lives, the more logs, backups and laptops it gets copied into. No standard sets an interval for API keys, so pick one your automation can run without a person. A quarterly rotation that happens beats a monthly one that gets skipped.
| Key type | Lifetime | Rotation policy |
|---|---|---|
| Short-lived access token | Under 1 hour | Expires on its own |
| Service-to-service key | Months | Automated schedule with an overlap window |
| CI/CD deploy key | Months | Scheduled, and when someone with access leaves |
| Long-lived integration key | Months | Scheduled, and immediately on a suspected leak |
The overlap window is what makes rotation painless: issue the new key, accept both for 24 to 48 hours, confirm traffic has moved, then revoke the old one. When a key does leak (a public repo, a ticket, a breach dump), revoke it the same hour. Scrapers read new public commits within minutes, so assume someone already has it.
Keep keys out of the places they leak
Most key leaks are a key in the wrong file. The case I've run into myself was an API key written straight into the code. It was moved to .env, removed from the code, and replaced with a newly generated key, because the old one had already been in the repository. In my projects .env always goes into .gitignore, and production values are encrypted with Ansible Vault and handed to the app at deploy time. Securing your .env files covers the cleanup when one slips through.
The other usual places:
- Frontend code. Any key in client-side JavaScript is public. The browser should call your backend, and the backend holds the key.
- Logs. Request loggers and error trackers capture headers, so redact
AuthorizationandX-API-Keyat the logging layer. - Chat and tickets. Treat any key pasted into Slack or Jira as exposed and rotate it.
Run a scanner in CI as well (gitleaks or trufflehog), with a custom rule for your own key format as shown above. For production, a secret manager such as HashiCorp Vault, AWS Secrets Manager or Doppler adds encryption at rest, access logs and rotation hooks, and the app reads secrets at runtime instead of baking them into an image.
If you're designing this today: a 43-character alphanumeric body from the Secret Key Generator or crypto.randomBytes, your own prefix with a scanner rule, a SHA-256 or HMAC digest in the database, and the first rotation date written into the same pull request.
FAQ
What's the difference between an API key and an access token?
An API key is usually a long-lived static credential you issue and revoke yourself. An access token (OAuth, JWT) is usually short-lived, scoped and issued by an auth flow, so a leaked one expires on its own.
Can I use a JWT as an API key?
You can, but you lose two things: a signed JWT stays valid until it expires unless you keep a denylist, and its payload is only base64url-encoded, so anyone holding it can read the claims (paste one into the JWT Decoder to see). For a long-lived key, a random opaque value with a server-side lookup is simpler to revoke.