ALL POSTS
api keyssecurityapi designengineering

API key format validation: what a checksum actually buys you

The received wisdom is that a checksum lets you reject bad API keys without hitting your database. I benchmarked it, and the saving is not where anyone says it is.

Sorower

Sorower

Co-founder

Aug 4, 202615 min read
In this article

Every article about API key format validation lands on the same advice: give the key a prefix, fill it with entropy, and stick a checksum on the end so you can reject bad keys without touching your database. I agreed with all of it. Then I benchmarked the last part.

On this machine, a full format gate (prefix check, length check, charset check, CRC32 checksum verify) costs 348 ns. Hashing the same key with SHA-256 and looking it up in an in-process Map costs 379 ns.

That is not a saving. That is a rounding error with a good publicist.

The checksum is still worth adding. Just not for the reason everyone repeats. What follows is the measurement: what each layer of the check actually catches, what it silently misses, what the gate really costs, and the bug in the shape check itself that behaves differently in six languages I tested it in.

The format the industry converged on

There is a genuine standard here, even if nobody calls it one. GitHub's secret scanning partner program spells out what it wants from a provider's tokens, and the list is short: "a uniquely defined prefix," "high entropy random strings," and "a 32-bit checksum." Three parts. That is the whole design.

Infographic: the three parts of an API key, showing prefix, random body and checksum as three cards

GitHub also shipped it themselves. Its token format post describes ghp_, gho_, ghu_, ghs_ and ghr_ prefixes followed by a Base62 body, with the last six characters holding a Base62-encoded CRC32 of the token data. GitHub's stated reason for the checksum was to "virtually eliminate false positives for secret scanning offline" and it expected the prefixes alone to cut false positives to 0.5%.

Stripe documents the other half of the convention, the environment tag: sk_test_ and sk_live_ for secret keys, pk_ for publishable, rk_ for restricted. The prefix is not decoration. It is doing three jobs at once.

Infographic: three jobs of a key prefix, routing, support triage and leak detection

Here is a working implementation in that shape. Forty characters of CSPRNG output, six characters of checksum, one regex.

import { crc32 } from "node:zlib";
import { randomInt } from "node:crypto";

const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const PREFIX   = "acme_live_";
const BODY_LEN = 40;
const SUM_LEN  = 6;
const SHAPE    = /^acme_live_[A-Za-z0-9]{46}$/;

function base62(n, len) {
  let out = "";
  for (let i = 0; i < len; i++) { out = ALPHABET[n % 62] + out; n = Math.floor(n / 62); }
  return out;
}

// The checksum covers the prefix as well as the body. That choice matters; see below.
function checksum(covered) {
  return base62(crc32(Buffer.from(covered, "utf8")) >>> 0, SUM_LEN);
}

export function mintKey() {
  let body = "";
  for (let i = 0; i < BODY_LEN; i++) body += ALPHABET[randomInt(62)];
  return PREFIX + body + checksum(PREFIX + body);
}

export function looksValid(key) {
  if (typeof key !== "string") return false;
  if (!SHAPE.test(key)) return false;
  const covered = key.slice(0, PREFIX.length + BODY_LEN);
  return checksum(covered) === key.slice(PREFIX.length + BODY_LEN);
}

Running it:

acme_live_sJRGKm8lYnRCkMmJNnWiNblT72YSvaD6AAiIyUgeEBhaag
intact            true
one char changed  false
two swapped       false
truncated         false
trailing newline  false
test key relabel  false

One note on that comparison: checksum(covered) === key.slice(...) is a plain string equality, not a constant-time one. That is deliberate. CRC32 is public arithmetic that anyone can run, so there is no secret to leak by returning early. The constant-time discipline belongs one layer down, where you compare the actual credential. We wrote about that layer in how to hash API keys.

What the shape check catches, and what it cannot see

"Reject malformed keys" is doing a lot of unexamined work in most articles. Malformed how? I minted 20,000 valid keys, corrupted each one in eight realistic ways, and measured what the regex caught versus what the checksum caught.

20,000 trials per corruption. Format: acme_live_ + 40 Base62 + 6-char Base62 CRC32. Node 26 on arm64.
CorruptionShape regex catchesChecksum catches
Truncate last character100%100%
Delete one middle character100%100%
Trailing newline100%100%
Leading space100%100%
Transpose two adjacent characters0%100%
Substitute one character0%100%
Flip the case of one letter0%100%
Swap the environment prefix100%0%

The pattern is clean once you see it. The regex checks the key's shape, so it catches every corruption that changes the shape and none that preserve it. Transposition, substitution and a case flip all produce a string of exactly the right length in exactly the right alphabet. To a regex they are indistinguishable from a real key.

Those three are also the corruptions humans actually make. Nobody deletes a character from the middle of a key by hand. They retype one wrong, or they fat-finger two, or their terminal helpfully title-cases something. A shape check catches none of it, which means without a checksum every one of those becomes a database lookup that misses, a 401, and eventually a support ticket that says "the key isn't working" with a screenshot attached.

The last row is the one worth sitting with, and I'll come back to it.

Your shape check has a different bug in every language

Here is where this got more interesting than I expected. The canonical shape regex is written the same way everywhere:

^acme_live_[A-Za-z0-9]{46}$

Looks unambiguous. It is not. $ means different things in different regex engines, and so does the function you feed it to. I ran the identical pattern against the identical inputs in six languages.

Same pattern, same inputs. "accepts" means the check passed a key it should have rejected. Verified 4 August 2026.
Language and idiom Clean key Trailing \n Trailing space Trailing \r\n Key inside a multi-line string
Node 26 /^…$/.test()passrejectrejectrejectreject
Go 1.26.4 regexp.MatchStringpassrejectrejectrejectreject
Java 21 matcher.matches()passrejectrejectrejectreject
Python 3.14.5 re.fullmatchpassrejectrejectrejectreject
Python 3.14.5 re.match(r"^…$")passacceptsrejectrejectreject
PHP 8.5.8 preg_match('/^…$/')passacceptsrejectrejectreject
Java 21 matcher.find()passacceptsrejectacceptsreject
Ruby 2.6.10 str =~ /^…$/passacceptsrejectrejectaccepts

Four of the eight idioms accept a key with a trailing newline. That sounds cosmetic until you remember where API keys come from: cat key.txt, a Kubernetes secret mounted as a file, a copy-paste out of a dashboard. Your format check says the key is well-formed, your store says no such key exists, and the developer on the other end gets a 401 for a key that is, character for character, correct.

Ruby is the one that made me stop and re-run it. In Ruby, ^ and $ are line anchors, always. The Regexp documentation is explicit that ^ matches "the beginning of a line" and $ matches "the end of a line," and that \A and \z are the string anchors. So this passes:

key = "junk\n" + "acme_live_" + ("a" * 46) + "\nmore junk"
key =~ /^acme_live_[A-Za-z0-9]{46}$/       # => 5   (truthy: it matched)
key =~ /\Aacme_live_[A-Za-z0-9]{46}\z/     # => nil (correct)

A Ruby service using the obvious pattern will accept an entire multi-line blob as a well-formed API key, so long as a well-formed key appears on any line inside it. Whether that is exploitable depends on what you do next, and I would rather not find out on a Friday.

The fix is boring in every language and you should apply it whether or not you think you have this bug:

LanguageUse thisNot this
Pythonre.fullmatch(pattern, key)re.match(r"^…$", key)
Ruby/\A…\z//^…$/
PHP/^…$/D (the D modifier)/^…$/
Javamatcher.matches()matcher.find()
Node, Goalready correct with ^…$ 

I confirmed each fix column in the same run. Every one of them rejects all five inputs except the clean key.

What the checksum covers is a design decision, not a detail

Back to that last table row. A key whose environment tag has been swapped from acme_live_ to acme_test_ passed the checksum 100% of the time. Of course it did. GitHub computes the checksum over the token data, and if you follow that literally the prefix sits outside the covered region, so changing it is invisible.

So I built both versions and swapped 20,000 keys from live to test:

Checksum coversEnvironment swap detected
The random body only0%
The prefix and the body100%

This is a real trade-off rather than a bug, and it is worth choosing on purpose. Covering the prefix means a key's environment tag is cryptographically welded to the key: nobody can relabel a test key as live by editing ten characters, and your gate rejects the relabelled key before it ever reaches your store. The cost is that you can never change a key's prefix without reissuing the key, because the checksum would no longer verify.

Most providers should take that trade. Prefix rewriting is not a workflow anyone wants, and "someone pasted a test key into the live config, and we found out from a 401 three days later" is a real support category. If you do want to relabel keys in place, that is a rotation problem, not a formatting one, and we covered the safe way to do it in API key rotation without downtime.

So what does the checksum actually buy you?

Now the number I opened with, in context. Same machine, same keys, warmed up:

Node 26.0.0, arm64. 3,000,000 iterations per row (750,000 for the hashed lookup), after a 100,000-iteration warmup.
OperationCost
crc32() call alone36 ns
Shape regex only166 ns
Shape regex + CRC32 checksum verify348 ns
SHA-256 hash + in-process Map lookup379 ns
Localhost HTTP round trip, keep-alive37,253 ns

Against an in-process cache, the format gate saves you nothing. 348 ns versus 379 ns is a 1.09x difference, which is noise. If your keys live in a local LRU cache in front of your store, you can skip the whole gate and just do the lookup, and no profiler will ever notice.

Against anything that crosses a process boundary, it is a different story. A keep-alive HTTP round trip to a server on the same machine, doing nothing, costs 37 microseconds. That is the floor for a Redis call, a Postgres query, or a validation API. The gate is 107x cheaper than the floor, and the floor is optimistic by a wide margin once a real network is involved.

So the honest version of the advice is: the checksum is not a CPU optimization, it is a way to not make a network call. If you were about to add one because you read that it is fast, and your lookup is already local, you are adding code for no reason. If your lookup leaves the process, it earns its place immediately, and it earns it three more times over:

  • Metered lookups. If you pay per request to whatever validates your keys, garbage traffic is a line item. A gate that rejects it locally removes it from the bill.
  • Pipeline noise. Requests that never reach the store also never reach your rate limiter counters, your logs, or your analytics. Internet-wide scanners spraying /api?key=test should not be shaping your dashboards.
  • Offline leak detection. This is the one GitHub actually built it for, and it does not run on your servers at all.

The leak-detection half, which is the actual point

A scanner reading a public repository has no access to your database. All it can do is look at a string and decide whether it is probably a live credential. A prefix narrows the candidates; a checksum settles it.

I ran the arithmetic as an experiment: ten million random strings with the correct prefix, the correct length, and the correct alphabet.

10,000,000 random strings with the RIGHT shape (correct prefix, length, charset):
  passed the shape regex           : 10,000,000
  passed the 6-char CRC32 checksum : 0
  checksum space = 62^6            = 56,800,235,584

Every single one looked like a key. None of them was one. That gap is precisely what a secret scanner is buying, and it is why GitHub asks partners for a 32-bit checksum rather than just a prefix.

The program itself is a genuinely good deal and under-used by small API providers. You give GitHub a name and a regex; GitHub scans public repositories and, when it matches, sends an HTTP POST to an endpoint you host, containing the token, the type, and the source where it was found, signed with a key you verify via the Github-Public-Key-Identifier and Github-Public-Key-Signature headers. You revoke the key and email the customer before anyone else finds it. Building that endpoint is an afternoon. Retrofitting a checksum into a key format after you have a million keys in the wild is not.

Mickey Gousset's deep dive is the most thorough walkthrough of how the scanning side works if you want to see it from the other direction:

Video thumbnail: GitHub Secret Scanning Deep Dive, a 48-minute walkthrough of secret scanning features

Questions you are probably about to ask

Does the checksum make keys easier to forge?

No, and it does not make them harder either. CRC32 is public arithmetic; anyone can compute a valid checksum for any body they like. The gate stops accidents, typos and untargeted scanner noise. It does not stop an attacker who has read your docs, and it was never supposed to. If you want a suffix that proves the key came from you, that is an HMAC with a server-side secret, not a CRC. It costs more and it buys authenticity instead of integrity.

Should I strip whitespace instead of rejecting it?

Trim the input, then validate the trimmed value, and reject anything that still fails. Trimming is forgiving in exactly the case where forgiveness is free (a stray newline from a file read) and it does not weaken anything, because the checksum still has to verify afterwards. What you should not do is trim after validating, which is how you end up with a key that passed the check and a different string reaching your store.

How long should the checksum be?

Six Base62 characters holds a full 32-bit CRC with room to spare, which is what GitHub asks for and what the ten-million-string run above exercised. Shorter suffixes shrink the space fast: four characters is about 14.8 million combinations, three is about 238,000. Since the whole point is that a scanner can trust a match, err long. Six characters costs you six characters.

What about keys I have already issued?

You cannot retrofit a checksum onto keys that are already in customers' environment variables. What you can do is issue the new format going forward and make the gate conditional: if the key matches the new shape, verify the checksum; if it matches the old shape, skip straight to the lookup. The gate gets more effective as old keys age out, and you get a free migration metric, since the share of traffic still on the legacy shape tells you exactly when you can cut it.

API key format validation is rung zero of the rejection ladder

Diagram: four checks before the lookup, prefix then length then charset then checksum, then the database lookup

Format validation is the cheapest thing in your auth path, so it goes first, before the lookup, before the hash, and before the rate limiter. That ordering matters more than it looks. In rate limiting failed authentication we measured what happens when a limiter sits behind the auth check: 300 guessed keys from one address produced 300 rejections and zero 429s, because the limiter never saw the requests that failed. A format gate is the layer that makes that flood cheap regardless of where your limiter sits, since a request rejected in 348 ns costs you almost nothing to absorb.

The full ladder, cheapest first: shape check, checksum verify, store lookup, credit or quota check. Each rung is more expensive than the last, and each one should reject as much as it can before handing anything upward.

Where ReqKey lands on this

Honest accounting, because this is our own product and you should know the shape of it. ReqKey issues keys as {prefix}_{random body}. The prefix is yours: POST /key/create takes an optional prefix field and defaults to your project name, and a reroll preserves it, so a rotated key keeps the same label. That gives you the routing, support-triage and scanner-detection value described above.

What it does not give you is the checksum. There is no checksum component in the issued key today, so the "verify the suffix before you call anything" half of this post is something you would implement on your own key format rather than get for free from ours. If offline scanner detection with near-zero false positives is a hard requirement for you right now, that is a real gap and you should weigh it.

What you do get is the layer underneath. Credits and rate limits both hang off the consumer, so a key that passes your gate resolves to an identity with a quota attached in one call. The free tier is 100,000 requests a month, which is more than enough to wire up a gate and watch what it actually rejects in your own traffic.

Key takeaways

  • The checksum is a network optimization, not a CPU one. Measured at 348 ns for the full gate versus 379 ns for a local hashed lookup, it saves nothing in-process. Add it because your lookup crosses a process boundary, is metered, or feeds a pipeline you want kept clean.
  • Your shape regex and your checksum catch disjoint failures, so you need both. The regex caught 0% of transpositions, substitutions and case flips; the checksum caught 0% of environment-prefix swaps. Neither is redundant.
  • Stop writing ^…$ and start writing a full-string match. Four of eight idioms tested accepted a key with a trailing newline, and Ruby's =~ /^…$/ accepted a valid key buried in a multi-line blob. Use re.fullmatch, \A…\z, the PHP D modifier, or Java's matches().
  • Decide deliberately whether the checksum covers the prefix. Covering it detected 100% of live-to-test relabels versus 0% when the checksum covered only the body. The price is that a key's prefix becomes immutable.
  • Register the prefix with GitHub's secret scanning program before you have a million keys out there. The endpoint is an afternoon of work; changing your key format later is not.

If you want to see how much of your traffic a gate like this would actually turn away, the answer is in your logs rather than in any article, including this one. Instrument it before you tune it. And if the identity layer underneath is the part you would rather not build, that is the part ReqKey does: prefixed keys, consumer-level quotas, and a validation call that returns the identity and the remaining balance together.

Share this post

Put your API keys on autopilot.

Keys, credits, plans, and real-time traffic analytics — free for your first 100k requests a month.