API Keys in Symfony: Shown Once, Stored as a SHA-256 Hash, Limited to One Endpoint

By Lars Nielsen ·

In October, CaRMen, my CRM, got an endpoint for something other than a logged-in person: a website contact form that should be able to create a lead without anyone logging in. That needs an API key, and an API key is a password that's meant to be pasted into other systems. The design that went in covers three things: the CRM can't leak the keys it has handed out, a key can do exactly one job, and a leaked key can be switched off without touching anything else.

The key: a public prefix and a secret

An admin creates a named key on an API Keys page. The key looks like this:

  • crm_<prefix>_<secret>

The prefix is 8 hexadecimal characters from 4 random bytes. It isn't secret: it identifies the key, and the admin page shows it so you can tell keys apart. The secret is 24 random bytes, base64url-encoded. The full key is shown once, right after it's created, and never again, because the CRM doesn't keep it. It stores the SHA-256 hash of the full key, the prefix, the key's name, its scopes, who created it, and timestamps for creation, last use and revocation.

Checking a key without storing it

A request presents the key in an X-Api-Key header. Checking it takes three steps:

  • Pull the prefix out with a regular expression that also checks the shape of the whole key. Anything that isn't shaped like one of CaRMen's keys stops here.
  • Look up the stored key by its prefix, which is unique.
  • Hash the presented key and compare it with the stored hash using hash_equals, which PHP documents as a timing-attack-safe comparison: it doesn't leak information about the stored value through how long it takes.

The prefix gives each key an identifier that is safe to show and to look up by, so the lookup never involves the secret part, and the admin page can show which key is which.

A revoked key fails the same way an unknown one does. Unknown, malformed and revoked keys all get the same answer, "Invalid API key.", with a 401, so an attacker learns nothing about which keys exist or used to exist. Revoked keys stay on the admin page, marked as revoked, so the history of who had access isn't lost.

One key, one job

The lead intake endpoint, POST /api/leads, sits behind its own firewall in Symfony's security configuration. That firewall matches only ^/api/leads, is stateless, and accepts nothing but the API key authenticator: no session, no user login. The rest of the API is behind a different firewall that expects a logged-in user's token, so presenting an API key anywhere else gets you nowhere.

Inside that firewall, a key authenticates as an integration, not as a person. Its roles come from its scopes: the scope leads:create becomes the role ROLE_API_LEADS_CREATE, and the access rule for /api/leads requires exactly that role. A key created with a different scope in the future wouldn't get into this endpoint.

Each key also has its own rate limit: 60 requests a minute, as a sliding window keyed by the key's prefix. Over the limit, the endpoint answers 429 with a Retry-After header, so a well-behaved form backend knows when to try again. A form that's being abused uses up its own key's allowance, not anyone else's.

What the endpoint does with a lead

A submission with a new email address becomes a contact with the status "lead". If the company name matches an existing account it's linked to that, otherwise a new account is created. A known email address isn't changed; the submission is added to that contact as a note instead. Either way, the message and where it came from are always stored as a note, so nothing a visitor typed gets lost. A short document in the repository describes the endpoint for whoever builds the form side.

The bug the page route found

Adding the admin page turned up one unrelated problem. Opening /api-keys directly in the browser gave a 500. The frontend's nginx config, and the Vite dev server's proxy, sent every path starting with /api to the backend, and nginx's location /api is a prefix match, so /api-keys matched it too. The backend had no such route. The fix in nginx was one character, location /api/ with the trailing slash, and in the Vite proxy the pattern became the regular expression ^/api/. Page routes that merely start with "api" belong to the single-page app again.

None of this is unusual for API key handling, and that's the point. Shown once, stored hashed, looked up by a public prefix, limited to one endpoint and one role, rate-limited per key, revocable: each of those is a small decision, and together they mean a leaked key is an inconvenience rather than a breach.

Filed under

Leave a comment

Comments are reviewed before they appear.

Basic HTML allowed: links, emphasis, code and lists. Web addresses become links automatically.