Skip to content

API keys, scopes and the test/live split

Why the environment is visible in the key itself, what the two permission levels really allow, and how keys are stored so that a database dump yields nothing usable.

PlatformBeginner9 min readUpdated

01The environment is in the key

A key is a prefix plus twenty-four random bytes in base64url — forty characters in total, of which the first eight tell you, and anyone who ever sees it, which world it acts on.

TL;DR

Eight characters of environment, thirty-two characters of secret. The environment travels inside the credential, so a test key in a production log is something a person can notice rather than something you find out about when the mail arrives.

ms_live_V2h5IGFyZSB5b3UgcmVhZGluZyB0aA   ← production. Real recipients.
ms_test_aXMgYmFzZTY0IGluIGEgZG9jcz8g   ← the test environment.
PartWhat it isWhy it is that
ms_live_ · ms_test_Eight characters of prefixThe environment, visible in the string itself — in a Slack thread, a screenshot, a logger’s output, or read aloud on a call. The alternative design, where the environment lives in a separate MAILYSEND_ENV variable, has no such moment.
24 random bytesbase64url, thirty-two characters192 bits of entropy — not a number chosen to sound impressive, just comfortably past the point where guessing is the attack. Nobody brute-forces one of these; they find it in a repository, a CI log or a screenshot.
40 characters in totalPrefix plus secretThe whole token, returned in exactly one API response and never again.
ms_live_V2h5…dGhpThe stored preview: first twelve, ellipsis, last fourTwelve is not arbitrary — the prefix is eight, so the preview shows the environment plus four characters of the secret. Enough to match against your secrets manager, nowhere near enough to be a credential.
^ms_(live|test)_[A-Za-z0-9_-]{20,}$The shape check, before anything elseA token that does not match is rejected on the spot — no hash, no cache read, no database round trip. A scanner spraying random bearer tokens at /v1 should cost you a regular expression, not a query.

This mirrors Stripe's scheme deliberately, and not out of flattery. Every property in that table is about handling rather than about key length, because handling is where keys are actually lost.

02Full access and sending access

There are exactly two permission levels. Not a scope matrix, not a role builder, not a policy language — two values, and you can hold both of them in your head while looking at a form.

TL;DR

full_access carries the wildcard scope; sending_access carries exactly one, emails:send. Everything below is what that one scope does and does not reach.

full_access — the default
  • Everything the API can do: send, read messages and events, manage contacts, domains, templates, webhooks and other keys.
  • It is the default, and it is the one to think twice about.
sending_access — one scope
  • Exactly one scope, emails:send. No writes to contacts, domains, webhooks or other keys.
  • The right credential for an application server whose only job is to put messages in the queue.
ScopeHeld byWhat it guards
emails:sendBoth levelsPOST /v1/emails and POST /v1/emails/batch
contacts:readfull_access onlyGET /v1/contacts/search
contacts:writefull_access onlyCreating, importing, updating and deleting contacts
audiences:writefull_access onlyCreating and editing audiences
segments:writefull_access onlyCreating and editing segments
automations:writefull_access onlyCreating, editing and running automations
inbound:writefull_access onlyInbound routing configuration
webhooks:writefull_access onlyCreating, updating, deleting and test-sending endpoints
api_keys:writefull_access onlyMinting and revoking keys

The refusal names the scope it wanted: “This API key is limited to sending; `webhooks:write` requires a full-access key.”

Two rather than twenty is the actual argument here. A permission model people do not understand is a permission model everybody sets to admin. A twelve-checkbox scope matrix looks more rigorous and produces worse outcomes, because the person creating the key at four in the afternoon does not know which six of the twelve their integration needs, and the safe-feeling move — tick them all — is the unsafe one.

And sending_access genuinely is the common case. Most integrations are an application server that renders a receipt and posts it to /v1/emails. If it is ever compromised, the difference between the two levels is the difference between “somebody sent mail from our domain” and “somebody has our mailing list”.

POST /v1/api-keys
{
  "name": "checkout-service",
  "permission": "sending_access",
  "domain_id": "dom_4Rk",
  "expires_at": "2027-01-01T00:00:00Z"
}
EXPIRY IS THE HONEST WAY TO LEND ACCESS
A key can carry an expires_at, and authentication enforces it: past that moment the key is refused exactly as a revoked one is. It is the right shape for handing a contractor access, because a credential that stops working on its own is one you cannot forget to revoke. Set at creation, alongside the permission.

Creating a key is itself a privileged action: it needs the api_keys:write scope and at least a developer role. A sending_access credential cannot mint itself a better one, which is the property that makes the narrow scope worth anything at all.

03Why a key is shown exactly once

The token is generated, returned in exactly one API response, and then it is gone. What stays behind is a SHA-256 hash and a twelve-character preview. There is no endpoint that returns a token, and no query in the codebase that reads the hash into a response body.

TL;DR

Authentication hashes what the caller presented and looks for that digest. The same direction, never the reverse — which is why a database dump yields nothing usable.

SHAPE
Regex
no hash, no I/O
HASH
SHA-256, hex
CACHE
KV ak:<hash>
300s TTL
ROW
revoked_at · expires_at
then the environment

This is the property worth being unfriendly about. If a key could be read back, the database would contain a set of working credentials, and every backup, every replica, every debugging export and every support engineer with read access would be holding them too. You cannot present a SHA-256 digest to /v1/emails and have it send anything.

So what is the preview for? Telling two keys apart. It is what lets a colleague say “the one ending dGhp” in an incident channel without saying anything dangerous.

$ curl -s $BASE/v1/api-keys -H "Authorization: Bearer $KEY"
{ "data": [
{ "id": "key_9Fb", "name": "checkout-service", "token_preview": "ms_live_V2h5…dGhp",
"permission": "sending_access", "environment": "live", "revoked_at": null }
] }

One operational detail that matters later: resolving a key hits the database once and is then cached for five minutes, because the send path cannot afford a lookup per request. Revocation does not wait out that cache — it deletes the cached entry in the same request, and does so before responding, so a 200 from the revoke call means the key is dead now rather than dead soon.

04Rotating without an outage

Rotation is three steps in one specific order. The order is the whole content of this section, because two of the six possible orderings work and the other four contain a window where production is holding a credential that no longer authenticates.

TL;DR

Overlap, cut over, revoke. In that order, nothing is ever offline.

1 · Overlap — create the new key while the old one still works

Mint the replacement with the same permission, the same domain pin, and a name that says when and why. Nothing is using it yet. Both keys are now valid, which is the entire point: there is no instant at which zero keys work.

2 · Cut over — deploy the new value

Update the secret in your secrets manager and roll your services. Then wait for every long-lived process to actually pick it up — a worker that read its environment at boot three weeks ago is still using the old key no matter what your configuration says, and a cron job that runs monthly has not run yet.

3 · Revoke — retire the old key

Only once nothing is using it. Revoking marks the row rather than deleting it, so the key that a future investigation cares about is still there to be named.

Overlap → cut over → revoke
  • Two valid keys for the duration, so there is no instant at which zero work.
  • Rotation becomes a non-event, and a non-event is something you will actually do quarterly instead of never.
Revoke → deploy, or deploy → mint
  • Revoking first is a deliberate outage as long as your deploy takes, plus however long it takes somebody to notice.
  • Skipping the overlap deploys a key that does not exist yet — the same outage, with a more confusing error message.
ROTATE ON A CALENDAR, NOT ON AN INCIDENT
A team that has rotated a key on a quiet Tuesday knows how long step two really takes in their environment. A team that has never rotated one is discovering that during an incident, under time pressure, while also trying to work out what leaked. The rehearsal is most of the value.

05What to do about a leaked key

A key is in a public repository, a CI log, a screenshot in a ticket, or a client-side bundle. The order here is not the same as rotation, and getting it right matters more, because every minute you spend understanding the leak is a minute the key still works.

TL;DR

Revoke first. Investigate second. Revocation is cheap and you can always mint a replacement; the window will not still be there in ten minutes, and the investigation will.

1
Revoke
2
Mint a replacement
3
Deploy it
4
Purge the leaked value
5
Reconstruct what happened
$ curl -X DELETE $BASE/v1/api-keys/key_9Fb -H "Authorization: Bearer $ADMIN_KEY"
{ "object": "api_key", "id": "key_9Fb", "revoked_at": "…", "deleted": true }

That response is the confirmation, not an acknowledgement: the cached entry is dropped before it returns. Purging means rewriting the git history if that is what it takes — a revoked key in a commit is still a signal about your naming and your habits.

What the trail can establish
  • What was sent. Every message is a row, with its sender, its recipients, its subject, its environment and its timestamp, and every delivery event is queryable.
  • If the leaked key was used to send, that mail is in your message list and you can read it.
  • A sudden run of sends you cannot account for, or sends from a domain that service never uses, is the strongest evidence available — and it usually answers the question that actually matters.
What it cannot
  • Which key sent a given message. The row records the workspace, not the credential.
  • Whether a key was used at all, from the key resource itself.
  • The practical answer to both: give each integration its own sending domain, so the message carries the attribution the row does not.

Revocation marks the row rather than deleting it. The key stays listed, with its name, its preview, its permission and the moment it was retired. An audit trail that erases the credential an incident was traced to is not an audit trail, and “there is no record of that key” is not a sentence you want to write in a post-mortem.

Finally, prevention that actually works: keep ms_live_ out of anything a browser downloads, add a secret scanner to CI that fails the build on the prefix — it is a distinctive, greppable eight characters, which is another quiet argument for the scheme — and prefer sending_access everywhere it will do. If an agent is going to be holding one of these, the agent inbox guide covers the confirmation gate that sits in front of sending regardless of what the key allows.

What just happened

You know what a key looks like, why the environment is baked into the string rather than kept in a config file somewhere else, and why nobody can ever read one back to you. The thing to internalise now, while nothing is on fire, is the rotation order: overlap, cut over, revoke. Doing it in any other order gives you a window where production has no working credential, and that window always turns out to be during a deploy.

Common questions

Read next

YOUR ACCOUNT, YOUR MAIL

Nothing to sign up for. Just deploy it.

Every guide on this site describes software you run yourself.