# 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.

Source: https://hyperjobs.io/blog/a-key-you-cannot-recover

*HyperJobs Team · 14 April 2026 · 8 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.

<Flow
  caption="The plaintext exists in one response body and in your clipboard. After that, only the hash."
  steps={[
    { label: "Key created", note: "hj_live_ + 48 hex" },
    { label: "Shown once", note: "copy it now" },
    { label: "SHA-256 stored", note: "the hash, not the key" },
    { label: "Plaintext gone", note: "unrecoverable by anyone", accent: true },
  ]}
/>

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.

<Note>
  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.
</Note>

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>` |
| --- | --- | --- |
| Precedence | **Wins** if both are present | Fallback |
| Where the key lands | The header, and nowhere else | The URL — proxy logs, browser history, `Referer` |
| Works from a browser | No — see below | Yes |
| When to use it | Everywhere you can | When 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

<Danger title="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](/docs/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 `429`s 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.
</Danger>

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`](/docs/api-reference/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.

<Steps>
  <Step title="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.
  </Step>
  <Step title="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.
  </Step>
  <Step title="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.
  </Step>
  <Step title="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.
  </Step>
</Steps>

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 `401`s in production.

The full reference is [authentication](/docs/authentication).
