ALL POSTS
api keyssecurityhashingapi design

How to hash API keys (and why bcrypt is the wrong tool)

bcrypt turns API key lookup into a full table scan, and past 72 bytes it will verify the wrong key as the right one. Both failures measured, plus the pattern that replaces it and how to migrate when you can't rehash.

Sorower

Sorower

Co-founder

Jul 31, 202615 min read
In this article

The pull request looked responsible. Someone had noticed our API keys were sitting in Postgres in plaintext, opened a fix, and reached for the thing every security checklist tells you to reach for. bcrypt. Ship it?

Yeah, no.

If you are working out how to hash API keys, bcrypt is the answer sitting in most of the top search results, and it is the wrong one. Not "suboptimal." Wrong in two specific, measurable ways: it makes looking the key up impossible without scanning your entire table, and in at least one widely used library it will cheerfully tell you that two different keys are the same key.

This post shows both failures with code you can run, then the pattern that actually works, then the part nobody writes: how to migrate off bcrypt when you no longer have the plaintext keys to rehash.

Why everyone reaches for bcrypt first

The reasoning is sound, right up until you change what you are hashing.

Passwords are terrible secrets. Humans pick them, humans reuse them, and the search space of things humans pick is small enough to enumerate. So password hashing functions are deliberately, expensively slow. bcrypt, scrypt and Argon2 exist to convert "attacker dumped your users table" into "attacker now has a very large compute bill." Slowness is the security property.

Computerphile's explainer on password cracking is the clearest walk-through of why that trade is worth making, for passwords:

Password Cracking, a Computerphile video explaining why slow hashing protects human-chosen passwords

Now look at an API key:

import secrets
key = "sk_live_" + secrets.token_urlsafe(32)
# sk_live_WRGDR-KUzwJkuXFuoZ54vTbfSgVXWqHAxqyg00xhuf8

That is 32 bytes straight from the OS entropy pool. 256 bits. Nobody chose it, nobody reused it on their email account, and there is no dictionary of likely values because there is no "likely." A password KDF is a workaround for humans picking bad secrets, and your API key was not picked by a human. Paying for the workaround buys you nothing.

The second difference is the one that shows up in your latency graphs. A password is verified once, at login. An API key is verified on every single request. Here is bcrypt on an Apple Silicon laptop, Python 3.14.5 with bcrypt 5.0.0:

FunctionCost per verificationDeterministic (indexable)?Input limit
SHA-2560.23 µsYesNone
HMAC-SHA2560.78 µsYesNone
Argon2id (library defaults)21.4 msNoNone
bcrypt, cost 1039.9 msNo72 bytes
bcrypt, cost 12159.5 msNo72 bytes

Measured on one core, so treat the absolute numbers as a shape rather than a benchmark for your hardware. The shape is what matters: on the same machine, bcrypt at cost 12 came out around 700,000 times more expensive per verification than SHA-256. At a modest 100 requests per second, verification alone wants about 16 CPU-seconds of work every wall-clock second. You will be buying cores to run a hash function whose slowness is protecting a secret that did not need protecting.

That is the boring objection. The two interesting ones are next.

Problem 1: you cannot look up a bcrypt hash

Infographic: three ways bcrypt breaks API key authentication, covering no lookup, the 72-byte ceiling, and cost per request

Hash the same key twice and look at what comes back:

import bcrypt
key = b"sk_live_WRGDR-KUzwJkuXFuoZ54vTbfSgVXWqHAxqyg00xhuf8"

print(bcrypt.hashpw(key, bcrypt.gensalt(12)).decode())
print(bcrypt.hashpw(key, bcrypt.gensalt(12)).decode())
$2b$12$ncJ//84B7S4D5Ayd6Ei7kOG/sB6/ma/Sx8znpl631nl.uNdWVfErm
$2b$12$yrPu9pYsQG8Sh7ltzF1nc.dGtQu9Yx0TFPglf21WUKc2LyQQ1QwV6

Same input, two completely different outputs. That is not a bug, it is the entire point: bcrypt generates a fresh random 16-byte salt per call and embeds it in the output string. Every row in your table has a different salt.

Which means this query can never work:

SELECT * FROM api_keys WHERE key_hash = $1;  -- $1 = bcrypt(incoming_key)

"Can I just index the bcrypt column?"

You can create the index. The database will never use it, because you cannot compute the value to search for. To hash the incoming key the way row 4,812 was hashed, you need row 4,812's salt, which is stored in row 4,812, which you have not found yet. Chicken, meet egg.

So the only correct way to verify a bcrypt-hashed API key is to read every row and run checkpw against each one until something matches. I measured that, with 1,000 keys in the table and the matching key deliberately last:

bcrypt linear scan, 1000 rows, match at row 999: 40206 ms
  -> per-row cost 40.21 ms; extrapolated 100k rows = 4020.6 s
sha256 indexed lookup: 0.25 us each (hash + dict hit), match at row 999

Forty seconds to authenticate one request against a thousand keys. Not "slow." Timed out, alerting, on fire. And it degrades linearly, so the day you cross ten thousand customers is the day authentication becomes a background job.

"What if I store the prefix in plaintext and only scan matching rows?"

Now you are getting somewhere, and you have also just proved the point. Narrowing the scan requires a deterministic, indexable column derived from the key. Once you have built that column, bcrypt's remaining contribution to your system is latency.

Problem 2: bcrypt only looks at the first 72 bytes

This one is not a performance note. It is an authentication bypass, and it is the reason this post exists.

bcrypt inherits a hard 72-byte input ceiling from the Blowfish key schedule it is built on. Anything past byte 72 is not hashed. It is not truncated with a warning, it is simply not looked at.

Python's bcrypt library changed its behaviour here. From the pyca/bcrypt 5.0.0 changelog:

Passing hashpw a password longer than 72 bytes now raises a ValueError. Previously the password was silently truncated, following the behavior of the original OpenBSD bcrypt implementation.

Loud failure, which is correct. Now here is the same test in Node, against bcryptjs 3.0.3 on Node 26:

const bcrypt = require("bcryptjs");
const crypto = require("crypto");

// A pepper prepended to every key, 72 bytes long
const pepper = crypto.randomBytes(36).toString("hex");

const keyA = "sk_live_" + crypto.randomBytes(24).toString("base64url");
const keyB = "sk_live_" + crypto.randomBytes(24).toString("base64url");
console.log("keys are different:", keyA !== keyB);

const hash = bcrypt.hashSync(pepper + keyA, 10);
console.log("keyB verifies against keyA's hash:", bcrypt.compareSync(pepper + keyB, hash));
keys are different: true
keyB verifies against keyA's hash: true

Read that output again. Two unrelated API keys. One hash. compareSync returns true. Every key in that system authenticates as every other key, and nothing in your test suite will catch it, because your test suite verifies the right key and it works fine.

The same thing happens without a pepper if your key format carries a long structured prefix:

const ns = "sk_live_org_5f8a2c1e-9b3d-4a67-8c21-77de0b9f4a13_env_production_region_us_east_1_";
// 81 bytes before the random part even starts

const k1 = ns + crypto.randomBytes(24).toString("base64url");
const k2 = ns + crypto.randomBytes(24).toString("base64url");

console.log("k1 === k2:", k1 === k2);
console.log("compare(k2, hash_of_k1):", bcrypt.compareSync(k2, bcrypt.hashSync(k1, 10)));
k1 === k2: false
compare(k2, hash_of_k1): true

Every key issued to that org authenticates as every other key issued to that org. The random suffix, the only part that was ever secret, starts at byte 81 and bcrypt stopped reading at 72.

A hash that ignores part of its input is not a hash of your key. It is a hash of your key's prefix, and your prefix was never the secret.

This is not hypothetical

FreshRSS shipped exactly this bug. A security improvement replaced SHA-1 with SHA-256 for nonce generation, which grew the nonce from 40 to 64 characters. The auth path called password_verify($nonce . $hash, $challenge), and with the longer nonce the 72-byte window covered the nonce plus eight characters of bcrypt's format header. None of the password-dependent bytes made it in. The function returned true for any password. PentesterLab has the full writeup, including the one-line fix: swap the concatenation order so the secret comes first.

Note what the trigger was. Not sloppiness. Somebody strengthening the crypto.

When does this actually bite you?

Fair question, and the honest answer is: only when more than 72 bytes of identical leading material sit in front of your entropy. A conventional key is fine:

key length: 51 bytes ("sk_live_" + token_urlsafe(32))
compare(otherKey, hash_of_key): no match   <-- correct

Three ways people get to the broken shape anyway, all of which look like good engineering at the time:

  • Prepending a pepper. bcrypt(pepper + key). A 32-byte hex pepper is 64 characters, so you are eight bytes from the cliff before the key contributes anything.
  • Long namespaced key formats. Embedding a tenant UUID, an environment and a region in the key is a genuinely useful design for routing and debugging, and it eats your 72 bytes.
  • Concatenating anything at all. Version tags, key IDs, scopes. Every prefix byte is a byte of secret you gave up.

Python raising a ValueError looks like the library breaking your signup form. It is the library saving you.

How to hash API keys properly

Four-step diagram of an API key lookup: receive key, split prefix, indexed lookup, constant-time compare

Two columns. That is the whole pattern.

CREATE TABLE api_keys (
  id           bigserial PRIMARY KEY,
  consumer_id  bigint      NOT NULL REFERENCES consumers(id),
  key_prefix   text        NOT NULL,          -- shown in the UI, never a credential
  key_hash     bytea       NOT NULL UNIQUE,   -- HMAC-SHA256, the lookup key
  created_at   timestamptz NOT NULL DEFAULT now(),
  revoked_at   timestamptz
);

CREATE INDEX ON api_keys (key_prefix);   -- for "list this customer's keys"

key_hash carries the UNIQUE constraint because it is what you look up, and because you would rather the database reject an astronomically unlikely collision than silently accept it.

import hashlib, hmac, os, secrets

PEPPER = os.environ["API_KEY_PEPPER"].encode()   # from KMS / secret manager, NOT the DB

def issue_key(conn, consumer_id: int) -> str:
    raw = "sk_live_" + secrets.token_urlsafe(32)
    digest = hmac.new(PEPPER, raw.encode(), hashlib.sha256).digest()
    conn.execute(
        "INSERT INTO api_keys (consumer_id, key_prefix, key_hash) VALUES (%s, %s, %s)",
        (consumer_id, raw[:12], digest),
    )
    return raw          # the only time this value ever exists outside the caller

def verify_key(conn, presented: str):
    if not presented or not presented.startswith("sk_live_"):
        return None
    digest = hmac.new(PEPPER, presented.encode(), hashlib.sha256).digest()
    row = conn.execute(
        "SELECT id, consumer_id, key_hash, revoked_at FROM api_keys WHERE key_hash = %s",
        (digest,),
    ).fetchone()
    if row is None or row["revoked_at"] is not None:
        return None
    if not hmac.compare_digest(row["key_hash"], digest):
        return None
    return row["consumer_id"]

One indexed lookup. No scan. Constant work regardless of how many keys you have issued, and the whole thing costs well under a microsecond of CPU before the database round trip, which is the part you should actually be optimising.

Worth being precise about that compare_digest call, because it gets cargo-culted. When you look the row up by the digest, the database already proved equality and the line is belt and braces. It earns its keep in the other common shape, where you find the row by prefix and compare the secret in application code. There, a plain == short-circuits on the first differing byte and leaks the digest to anyone patient enough to measure. Use the constant-time comparison whenever your own code is the thing deciding whether two secrets match.

SHA-256 or HMAC-SHA256?

Plain SHA-256 is defensible. Your key has 256 bits of entropy, so an attacker holding the hash has nothing better than brute force over a space they cannot enumerate.

HMAC buys you one extra property for 0.78 µs: the pepper lives outside the database, in your secret manager or KMS. Dump the table and you still cannot check a guess, because you are missing half the function. That matters more than it sounds, because database dumps leak through backups, replicas and analytics pipelines far more often than application secrets do.

The rule that FreshRSS learned the hard way: the pepper goes in the HMAC key argument, never concatenated into the message. hmac.new(PEPPER, key, sha256) is safe at any length. hash(PEPPER + key) is the exact construction that broke.

"Don't I need a per-row salt?"

No, and this is the one place where copying password advice does real damage. Salts defend against precomputation: one rainbow table cracking a million users at once because they all picked hunter2. There is no rainbow table for 256 bits of randomness and there never will be. A per-row salt buys you nothing here, and it is precisely the thing that destroys your index.

What the prefix should carry

The plaintext prefix is doing three jobs, and none of them are authentication.

Identification in the UI. Your customer has four keys and needs to know which one to revoke. Store enough to disambiguate, display it as sk_live_WRGD…, and remember the maths: the key is 43 base64url characters covering 256 bits of entropy, so revealing four of them gives away at most 24 bits. There is plenty left.

Routing and environment. sk_live_ versus sk_test_ lets you reject a test key against production before you touch the database, and it saves a support round trip every time someone pastes the wrong one.

Leak detection. This is the underrated one. A distinctive, greppable prefix is what makes automated secret scanners work at all. GitHub runs a secret scanning partner program where providers register a pattern and get notified when a matching string is committed to a public repository. You cannot join that program with a key format that looks like every other base64 blob on the internet.

Migrating off bcrypt when you can't rehash

Four-step migration diagram: add columns, verify then backfill, track coverage, drop the scan

Here is where most advice stops and shrugs. You are convinced, you want the two-column pattern, and you cannot have it, because rehashing requires the plaintext keys and you correctly never stored them.

You do not need a backfill job. You need to notice that there is exactly one moment when your server legitimately holds a plaintext key: the instant a customer authenticates with it. Migrate lazily, at that moment.

def verify_key(conn, presented: str):
    digest = hmac.new(PEPPER, presented.encode(), hashlib.sha256).digest()

    # Fast path: already migrated.
    row = conn.execute(
        "SELECT id, consumer_id, revoked_at FROM api_keys WHERE key_hash = %s", (digest,)
    ).fetchone()
    if row is not None:
        return None if row["revoked_at"] else row["consumer_id"]

    # Slow path: the old bcrypt scan, only for keys we have not seen since the cutover.
    for legacy in conn.execute(
        "SELECT id, consumer_id, bcrypt_hash, revoked_at FROM api_keys WHERE key_hash IS NULL"
    ):
        if not bcrypt.checkpw(presented.encode()[:72], legacy["bcrypt_hash"]):
            continue
        # We are holding the plaintext. This is the only chance we get.
        conn.execute(
            "UPDATE api_keys SET key_hash = %s, key_prefix = %s WHERE id = %s",
            (digest, presented[:12], legacy["id"]),
        )
        return None if legacy["revoked_at"] else legacy["consumer_id"]

    return None

Every active key migrates itself on its next request. The scan shrinks on its own, which means it gets faster every day rather than slower. Two details worth getting right:

Make key_hash nullable with a partial unique index, and query WHERE key_hash IS NULL on the slow path so the scan only ever touches unmigrated rows. And note the [:72] on the bcrypt comparison: if you are on Python bcrypt 5.0.0 and any legacy key exceeded 72 bytes, checkpw now raises rather than truncating, and your migration path will throw on exactly the keys you most want to retire.

Then the honest part, which is the reason to plan this before you start. Dormant keys never migrate. A key that fires once a quarter will still be sitting on the scan path six months from now, and you cannot drop the bcrypt column until it is gone. So watch the coverage number, and when it flattens out, stop waiting. Name a date, email the customers still on legacy keys, and rotate them. Our guide to rotating API keys without downtime covers the overlap window that makes that a non-event for the people receiving the email.

Or don't store them at all

Every section above is work you are doing so that a table of secrets is slightly less catastrophic when it leaks. The other option is not having the table.

That is the job ReqKey does. Keys are issued and validated against a hosted store, and your service asks a question instead of running a lookup:

curl -X POST "https://api.reqkey.com/key/validate" \
  -H "Authorization: Bearer reqkey_xxx..." \
  -H "Content-Type: application/json" \
  -d '{"key": "prod_A1B2C3D4E5F6G7H8I9J0K1L2", "credits": 1}'
{"valid": true, "creditsRemaining": 4999, "creditsLimit": 5000}

No hash column, no index, no migration, and the credit deduction happens in the same call as the auth check. The full request and response shapes are in the keys API reference.

Now the part a vendor is supposed to skip. This trade is not strictly better, and you should know which property you are giving up. ReqKey's /key/details endpoint returns the key value when you look it up by keyId, because customers want to re-display a key in a dashboard rather than reissue it. That is a deliberate product choice, and it means ReqKey does not give you the irretrievable-hash guarantee that the two-column pattern above does. If your threat model genuinely requires "not even we can recover this key," build it yourself with HMAC-SHA256 and skip the vendor. What you get in exchange is one credential to protect instead of a whole table, plus quotas and usage data you would otherwise write twice.

Pick the one that matches what you are actually defending against. Both beat bcrypt. And if you are weighing hosted options generally, we priced six of them against one workload, including where ReqKey came out worse.

Key takeaways

  • Password KDFs solve a problem your API keys do not have. bcrypt and Argon2 are slow on purpose to protect low-entropy human-chosen secrets. A key from secrets.token_urlsafe(32) carries 256 bits of randomness, so the slowness is pure cost with no benefit.
  • A per-row salt makes indexed lookup impossible. bcrypt returns a different hash every call, so verification degrades to a full table scan: 40 seconds against a thousand rows in my measurement, growing linearly with your customer count.
  • bcrypt stops reading at byte 72, and two keys sharing those bytes will authenticate as each other. Verified in bcryptjs 3.0.3 and shipped for real in FreshRSS. Audit any code path that prepends a pepper, a tenant ID or a namespace before hashing.
  • Store an indexed HMAC-SHA256 digest plus a plaintext display prefix. The pepper belongs in the HMAC key argument, never concatenated into the message, and the prefix should be distinctive enough for leak scanners to recognise.
  • Migrate off bcrypt lazily, at verification time. It is the only moment you legitimately hold the plaintext. Then accept that dormant keys will never migrate themselves, and set a rotation deadline for the stragglers instead of waiting forever.

If you would rather not own any of this, ReqKey's free tier is enough to wire up /key/validate against a real service and see what your auth path looks like without a hash column in it. If you would rather own all of it, the schema and the migration above are yours. Just please do not leave bcrypt in the middle.

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.