DEV Community

EQIQs
EQIQs

Posted on Originally published at eqiqs.com

Why we hash API keys at rest — and what it costs in edge-function latency

Every API key we issue is stored as a SHA-256 digest. We keep the first eight characters in plaintext as a display prefix, and nothing else. If someone walks off with our key table, they get prefixes and digests — neither calls the API.

This is the reasoning behind that design, why we deliberately chose a fast hash over bcrypt, the collision bug that cost us a day, and the real p50/p95 latency from our Deno edge functions so you can judge the tradeoff for your own system.

Originally published on the EQIQs engineering blog.

Every EQIQs API key is stored as a SHA-256 hash. We keep the first eight characters of the key in plaintext as a lookup prefix, and nothing else. If someone walks off with a dump of our key table, they get a list of prefixes and a list of digests, and neither one lets them call the API.

This post covers why we chose that design over the obvious alternatives, what it costs at request time, and the real latency numbers from our edge functions so you can judge whether the tradeoff is worth it in your own system.

The problem with storing keys you can read

Most small API products store keys in a column you can select. It is convenient: you can show the key again in the dashboard, you can support it over email, you can grep for it when debugging. That convenience is exactly the failure mode. A readable key column means every backup, every log line that accidentally serializes a row, and every over-broad database role is a credential leak.

Treating an API key like a password removes that whole class of incident. The key is generated once, shown once, and never recoverable. If a customer loses it, they rotate it. That is a slightly worse support experience and a dramatically better security posture.

Why a prefix, and why SHA-256 instead of bcrypt

Hashing creates one practical problem: you cannot look up a row by a value you cannot reverse. Scanning every row and comparing hashes is fine at a hundred keys and unacceptable at a hundred thousand.

The fix is to make the digest itself the lookup key. We hash the presented key and select the row where the stored digest matches, on an indexed column — one index hit, no scan, no reversal. Separately we store the first eight characters of the key in plaintext as a display prefix. That prefix is never used to authenticate; it exists so keys are identifiable in dashboards, logs, and support tickets without anyone handling the secret. We learned that distinction the hard way: our own issuers mint keys with constant leading segments, so a prefix-based lookup collides across every key from the same issuer and locks out valid credentials. The digest is unique per key. Authenticate on the digest.

We use SHA-256 rather than bcrypt or argon2 here, which is the opposite of what you should do for user passwords. The reasoning: an API key is 256 bits of output from a cryptographic random generator, not a human-chosen string. There is no dictionary to attack and no realistic brute-force path, so the slow-hash property that protects weak passwords buys nothing. What it does buy is latency, on every single request, in a serverless runtime that is already paying for cold starts. Bcrypt at a sane work factor would have added tens of milliseconds to every authenticated call to defend against an attack that does not apply.

Rule of thumb: slow hashes for low-entropy human secrets, fast hashes for high-entropy machine secrets.

Doing it inside an edge function

Our API runs on Deno-based edge functions. The Web Crypto API is available in the runtime, so hashing needs no dependency at all:

async function hashKey(key: string): Promise<string> {
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
Enter fullscreen mode Exit fullscreen mode

Verification is one indexed select on the digest. Three details matter more than the hash itself:

  • Compare in constant time. A plain string equality on the digest leaks timing information. Compare byte by byte with an accumulator, or compare digests of the digests.
  • Never log the key. Log the prefix. We enforce this by only ever passing the prefix past the auth boundary; the raw key is out of scope after verification returns.
  • Give every key its own row and its own revocation flag. Shared keys across environments make revocation a customer-facing outage instead of a click.

What it costs: real latency numbers

Security arguments are cheap when nobody publishes the overhead. Here are 30 sequential requests to our public health endpoint, measured from outside our infrastructure over the public internet, including TLS and DNS:

  • Cold start (first request after idle): 1,058 ms
  • Warm p50: 212 ms
  • Warm p95: 683 ms
  • Warm minimum: 109 ms

The hashing step itself is not visible in that distribution. SHA-256 over a 40-character string is microseconds; the variance you see is network round trip and cold starts, not crypto. That is the entire point of the fast-hash choice: authentication is free relative to everything else in the request.

If you want sub-3-second worst case in an edge runtime, the things that actually move the number are:

  1. Cold starts. Keep the function's import graph small. Every heavy dependency pulled in at module scope is paid on every cold boot, whether the request path uses it or not. Lazy-import anything that only some routes need.
  2. Database round trips. One indexed lookup for auth, then one query for data. If your auth check needs a join, you have designed it wrong.
  3. Downstream AI or third-party calls. These dominate. Anything that calls a model should stream or return a job id, never block the request for the full generation.
  4. Region. An edge function that talks to a database in another continent is not an edge function. Co-locate them.

What we would do differently

Two things we would change if we were starting over. First, we would have added a last_used_at column from day one; it is the cheapest way to help customers find keys they forgot they issued. Second, we would have shipped scoped keys before unscoped ones. Retrofitting scopes onto keys that already exist means either a migration that silently widens permissions or a breaking change, and neither is a good conversation to have with an integrator.

Try it

The EQIQs API is self-serve with a free tier of 500 calls per month. The health endpoint above is public and unauthenticated if you want to measure the numbers yourself rather than take ours. Key issuance, rotation, and revocation are in your settings under the API tab.

Top comments (0)