All posts
Engineering

Your key crosses the wire exactly once

We store a SHA-256 hash of your API key and nothing else. What that buys, what it costs, and the CORS trap that breaks browser auth.

HyperJobs Team8 min read

You create a key in the dashboard. It renders once. You copy it, or you don't — and if you don't, nobody can show it to you again. Not you. Not support. Not whoever has production database access.

That's deliberate. It's worth understanding what it buys, because it also costs you something.

What actually gets stored#

A key is the prefix hj_live_ followed by 48 hex characters — 24 random bytes. The plaintext crosses the wire exactly once, at creation. What we keep is a SHA-256 hash of it.

  1. Key created

    hj_live_ + 48 hex

  2. Shown once

    copy it now

  3. SHA-256 stored

    the hash, not the key

  4. Plaintext gone

    unrecoverable by anyone

The plaintext exists in one response body and in your clipboard. After that, only the hash.

A hash is one-way. The row in our database is not a key and cannot be turned back into one — we authenticate by hashing what you send and comparing digests, which never requires the original. There's no recovery flow because it would require us to hold what we deliberately threw away.

A database breach can't leak working keys. The price is that losing a key means rotating it rather than looking it up.

If you lose one, the only procedure is: revoke it, create another, deploy. That inconvenience has a known cost. The alternative — a store we could read back to you — has an unbounded one, paid by everyone at once, on a day we don't get to choose.

Support will never ask for your key — there's nothing we could do with it that we can't do without it. A key pasted into a ticket is a key in an email thread, a ticketing system, and someone's inbox backup. Never send it to us.

You can hold up to 10 active keys at a time. Revoked keys don't count against that limit. That ceiling is what makes rotation possible without downtime.

Two ways to send it#

Authorization: Bearer <key>?api_key=<key>
PrecedenceWins if both are presentFallback
Where the key landsThe header, and nowhere elseThe URL — proxy logs, browser history, Referer
Works from a browserNo — see belowYes
When to use itEverywhere you canWhen you have no other option

Prefer the header. A key in a query string gets written to every log between you and us, and those logs outlive the request by however long someone's retention policy says — not a number you know.

The Bearer prefix is optional. Send it anyway.#

The server strips Bearer if it's there and otherwise treats the whole header value as the key. Authorization: hj_live_… works.

Send Bearer regardless. Not because we need it — because every other tool in the ecosystem expects it. Clients, proxies and gateways have all read the same RFC, and the first one to choke on a bare-key header will do it somewhere you weren't looking. The eight characters aren't worth the afternoon.

The CORS failure, mechanically#

This is the one that wastes the most time, so here's the mechanism rather than the rule.

Every response carries Access-Control-Allow-Origin: *. So a GET from page JavaScript reaches the API — permission granted, wildcard, on every response.

Then you add your key as an Authorization header, and it stops working.

CORS splits requests into simple and non-simple. A simple request goes straight out and the browser checks the response headers afterwards. A non-simple one gets checked first: the browser sends an OPTIONS request to the same URL asking whether the real request would be allowed. That's the preflight.

A custom Authorization header is exactly what makes a request non-simple. So the browser preflights — and the API has no OPTIONS handler. The preflight arrives as a request nothing is there to answer, is treated as unauthenticated, and fails.

Two details make this maximally confusing:

  • The preflight doesn't carry your key. It only describes the header you intend to send, so there is no world in which it authenticates, however correct your key is.
  • The failure isn't a 401. The browser blocks the real request because the preflight never approved it, so your fetch never sends it. What lands in your catch is a network or CORS error with nothing useful in it.

Your key was fine. Your request was never sent.

A ?api_key= request sends no custom headers. It stays simple, no preflight happens, and it just works.

"It just works" is the trap#

A browser-side key is a public key

Anything in browser JavaScript is readable by anyone who opens devtools. Minification is not obfuscation, an environment variable inlined at build time is a string in your bundle, and "it's only inside a fetch call" means it's in the network tab.

A key shipped to the browser is a key you have published.

And it carries your whole quota. Rate limits are per account, not per key — every key on the account draws from one shared pool. So a leaked key doesn't just burn itself, it burns the pool every other key is drinking from, and your production traffic starts getting 429s because of a bundle you shipped last quarter. Revoking it is the easy half; you still have to ship a new one to every browser that has the old one.

Call the API from your server and expose your own endpoint to your frontend.

The ?api_key= fallback exists because it's the only thing that works from a browser. That's a statement about CORS, not an endorsement.

Why 401 tells you nothing#

401 comes back when the key is missing, malformed, unknown, or revoked. The response doesn't distinguish:

JSON
{ "error": "invalid or missing api key", "hint": "Authorization: Bearer <key>" }

That's on purpose. An auth error that distinguishes is an oracle. "Malformed" versus "unknown" confirms whether a guessed value has the right shape; "unknown" versus "revoked" confirms that a string someone is holding was once a real, working key. Both hand free information to the one person who shouldn't have it.

The cost lands entirely on you. The same four words cover a typo, an unset variable that made your header the literal Bearer undefined, a key you revoked in March and forgot about, and one that was never yours. The API will not narrow it down. That's a defensible trade for an auth error and a bad one for a debugging experience, and it's both at once.

Narrow it locally instead. A key is hj_live_ plus 48 hex characters — 56 in total. Assert the length and the prefix in your config loader before you blame the server, and you'll catch most of the four cases without a request.

Verifying a key#

Not with /v1/health. It never reads one — it's the only endpoint that needs no key at all, so a revoked key, a truncated key and no key whatsoever all get the same 200. Health-checking with a broken key confirms the API is up, which was never the question.

The cheapest real check is one authenticated row:

BASH
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  "https://api.hyperjobs.io/v1/jobs?limit=1"

200 means the key is live. 401 means it isn't. /v1/meta does the same job. Read the status code, not the body — a key problem and an empty result set are identical if you only inspect data.

Scopes, honestly#

Keys carry a jobs:read scope. It's currently the only scope, every endpoint is readable with it, and there's nothing to configure. The field exists so write or admin scopes can be added later without reissuing keys.

So it does nothing for you today. It's forward-compatibility — a promise about our future migration cost, not a feature you can use. Worth recognising in a payload. Not worth a line of code.

Rotating without downtime#

There's no automatic rotation. The 10-key ceiling is the budget that makes the manual procedure safe — you're meant to run two keys at once for a while.

  1. Create the new key

    The old one keeps working while you roll. Copy the new one straight into your secret store — it's shown once, same as the first.

  2. Deploy it

    Update your secret store and let the change propagate everywhere. Everywhere is the load-bearing word — the monthly cron, staging, the analytics job nobody has touched in a year.

  3. Confirm the old key is idle

    The dashboard shows last_used_at per key. Wait until the old key stops moving. This step is the entire reason the procedure has no downtime — it's the only evidence you have that nothing is still holding the old key.

  4. Revoke the old key

    Revocation takes effect on the next request and is permanent. The key's usage history is retained for your billing records.

Skipping step three is how a rotation becomes an outage. Revoke first and last_used_at stops being a check and starts being a postmortem — you'll still discover the forgotten consumer, just from its 401s in production.

The full reference is authentication.