How Bike4Mind validates, scopes and revokes API keys
October 9, 2026
By Bike4Mind Engineering
A Bike4Mind API key is a bearer credential. Whoever holds the string can call the endpoints its scopes allow, bill usage to its owner, and in some cases mint further keys. That makes the key system a small piece of code with a large blast radius, and over the last few months a good part of the team has worked on it from different directions: how fast a key is checked, what a key is allowed to do, what happens when two writes to the same key overlap, and what happens to a key when its owner's account is blocked. This post walks through those changes in the open-source core and the reasoning behind each.
Every key-authenticated request passes the same chain before it reaches a handler.
Checking a key without a slow hash
Keys used to be validated the way passwords are: the stored record held a bcrypt hash, and every request paid a slow bcrypt compare. bcrypt's cost factor exists to slow down offline guessing of low-entropy secrets. An API key is long, random output, so there is nothing to guess, and the slow hash was pure latency on every call.
Keys are now checked against a fast SHA-256 digest instead of a slow bcrypt compare, and the full secret is never stored. The digest is deliberately unkeyed: a server-side pepper would be one more secret every deployment has to provision before the fast path works, and rotating it would silently invalidate every stored digest.
Existing keys were not migrated by a sweep. A key that predates the digest is checked the old way once, and if it is valid, the record is upgraded so the next check uses the fast path. The upgrade only applies if the record still holds the value that was just checked, so a key rotated in the meantime is left alone.
Limiting what a key can do
Scopes decide which endpoints a key can reach, and several gaps let a key reach further than intended.
The first was at creation. The UI only offered self-service scopes, but the service only checked that requested scopes were valid values, so a direct request to the creation endpoint could ask for scopes that were never meant to be self-service. Key-creation now checks an explicit allowlist before anything else runs. The self-service path rejects anything outside the self-service list, and the admin path permits a small additional set for service accounts while still refusing the scopes that should never be mintable through it.
The second was keys minting keys. A caller authenticated with an API key could create a new key with scopes the calling key did not itself hold, so a leaked narrow key could bootstrap a broader one for the same owner. Rotation already had a no-escalation rule; creation now applies the same check, and extends it to expiry, so a child key can no longer outlive or out-scope the key that created it. A browser session carries no calling key, so it is unaffected.
The third was admin routes. Those routes checked in the handler that the caller was an admin, but a route that declared no required scopes let any key owned by an admin through, whatever it was actually scoped for. Admin routes that accept API-key authentication now require an explicit admin scope, so a key without it is rejected even when its owner is an admin. A coverage test enforces this across the admin surface, with a small, explicit list of routes that disable API-key authentication entirely exempted.
The admin fix exposed a broader default: required scopes are opt-in, so a new route that declares none accepts any valid key. A second test scans the rest of the API and fails when a route declares no scope requirement and is neither browser-only nor unauthenticated, unless it is explicitly listed as an exception. That list can only shrink, so a new route has to choose its posture explicitly.
Writes that cannot undo each other
Several code paths write to the same key record: validation's own upgrades, revoke, rotate, configuration changes, spend accounting, and bulk deactivation. Some of them used to load the whole key, change one field, and write the whole document back. If another write landed in between the read and the write, it was silently reverted: a disabled key could come back active, or a spend counter could go back to an old value. Each writer now sends a targeted update containing only the field it changed, so one writer's change can no longer undo another's. A test against a real database forces these interleavings and confirms the old, whole-document style of write really did revert.
Revocation had a related problem: a revoke racing a bulk deactivation could each overwrite the other's record of what happened and when. The write now checks the key's current state as part of the same update, so whichever one disables the key first keeps its record intact.
The per-user key cap is now enforced together with key creation instead of as a separate check beforehand, and tests confirm that concurrent requests end at exactly the cap.
Keys follow their owner's account state
A way to deactivate all of a user's keys already existed, but nothing called it. Keys belonging to a banned, suspended, or disputed user stayed active, and the use-time account check was the only thing stopping them; lifting the block brought them back. Now, keys are switched off when the owner's account is banned, suspended, or disputed, as part of the same update that changes the account's state. Leaving the blocked state does not reactivate anything on its own.
The use-time check still runs on every request, using the same account-state check as every other way of authenticating. When that check rejects an API-key request, the rejection is logged with enough context, short of the key itself, that a rejected integration can be traced back from the logs.
Managing keys after they are issued
Smaller changes made the lifecycle easier to manage. Per-key rate limits used to be fixed at creation, so changing one meant revoking and reissuing the key. Owners and admins can now update them in place, and the bounds live in one module shared with creation, so the two paths cannot accept different values.
Revoked keys now record when they were revoked, by whom, and optionally why. The record only gets a timestamp on the transition to disabled, so re-revoking does not reset it, and keys disabled before the fields existed stay blank instead of getting a made-up date. The management tables show the result as a tooltip on the status chip. Revoked keys used to stay in the owner's list forever; they are now hidden behind a toggle and can be deleted. Delete only accepts a key that has already been revoked, so revocation always comes first. The delete is a soft delete, which keeps the record and its audit fields.
Finally, every mutating key route used to await its analytics event after the change had committed, so a failed analytics write returned a 5xx for an operation that had succeeded. A client reading that as retryable would repeat the mutation. Those routes now use a best-effort wrapper that logs the failure and returns. The original function still throws, because the one endpoint whose job is to write the analytics event has to report whether the write happened.
The pattern across these changes is to put each check where it cannot be skipped. Scope limits are enforced at creation and on every route, not left to the UI. Writes to a key carry the condition they depend on, so an overlapping write fails instead of reverting someone else's. Account state reaches the keys themselves, not only the request gate. Where a rule is easy to forget on new code, a test fails until the new route or caller states its posture.
All of this is in the open-source Bike4Mind repo: github.com/Bike4Mind/bike4mind.