Journal · 12 May 2026 · 6 min read

MCP server authentication: the three invariants we built it on

Most public examples of an MCP server ship with a single hardcoded API key in an environment variable. That is the obvious thing to do, and it is the thing the methodology's invariant library ruled out before we wrote a single line of PickNDeal Phase 9.

This post is the design rationale: the three rules from earlier projects that shaped the MCP server's auth, the implementation they produced, and the broader point — invariants compound across projects, which is what makes the methodology earn its keep on the second and third codebase, not the first.

Why env-var keys are the obvious-but-wrong default

The MCP transport is HTTP (or stdio). Authentication happens at the HTTP layer, the same as any REST API. Tutorial-shaped examples reach for the simplest primitive: MCP_API_KEY=... in a .env file. It works for a single internal automation calling its own server. It breaks the moment a second integration partner shows up — rotating the shared secret breaks every caller at once, you have no per-client audit trail, you cannot scope a read-only credential without redeploying.

We had not seen this exact shape on MCP. We had seen it before — on Stripe webhook secrets, on internal INTERNAL_API_KEY patterns in earlier work, on every JWT-signing scheme that conflated the keyholder with the user owning the keyholder. Each of those produced an invariant. By the time we got to Phase 9, three of them were already in the project library and the env-var-key approach was unbuildable as a starting point.

The three invariants, in the order they applied

Each is a one-paragraph file in the repo. The agent reads them as part of its system prompt on every code-edit session and refuses to violate them when it is editing related code. Codified, not advisory.

# invariants/auth/per-key-scopes-not-per-user.md
A credential must be able to do strictly less than its owning
user. Tying scopes to the user instead of to the key forecloses
least-privilege from the first integration onward. Scopes are an
array column on the credential record, checked at the dispatcher
with deny-by-default. Originated in earlier Stripe Connect work
where a refund-only role had to be issued without write access.

# invariants/auth/hash-secrets-at-rest.md
Never store an API key plaintext, even in your own database. A
DB dump should surface prefixes and one-way hashes only — never
callable credentials. This forces rotation to be create-new +
revoke-old (the correct shape) rather than mutate-in-place.

# invariants/webhooks/hmac-plus-timestamp-plus-event-id.md
Every outbound webhook is signed (HMAC-SHA256 over body),
timestamped (X-Timestamp, receiver rejects >5min skew), and
carries a unique X-Event-Id the receiver dedupes against.
Network retries are the environment, not a bug to fix. Originated
in Stripe Connect webhook handling.

The implementation they produced

With the three rules constraining the design, the shape fell out almost without choice. The full key the user copies is prefix.secret. We index the lookup by prefix (single-row fetch), store SHA-256 of the secret half, compare with a constant-time function. The table looks like this:

api_keys
  prefix      varchar(12) unique     // 'ap_3f9c8a72' — public-safe identifier
  hash        text                    // sha256(secret) — never store the secret
  scopes      text[]                  // ['orders:read', 'offers:write', ...]
  lastUsedAt  timestamp               // for dashboard + drift detection
  revokedAt   timestamp               // soft-revoke, never delete

Scopes are per-key, not per-user. The dispatcher rejects unknown tools by default. Rotation is create-new + leave-both-valid + revoke-old when the old key's lastUsedAt goes stale. Webhooks back to the client carry the signature, timestamp, and event ID set by the third invariant. None of these choices required thinking — the rules had already eliminated the alternatives.

Why this is the work, not the auth pattern

The MCP server shipped right the first time. Not because we are better engineers than the people writing the public examples — we are not — but because the invariants from previous projects had already codified the constraints that ruled out the wrong design. The first time you discover that secrets need to be hashed at rest, it costs you a production incident. The second time, it costs you nothing, because the rule is already in the library and the agent refuses to violate it when editing adjacent code.

That compounding is the bet of the agentic delivery method. The pattern in this post is unremarkable and almost not worth publishing on its own. The fact that it ships unchanged across the next ten systems we build — without three production incidents per system to re-derive it — is the work.


More on the methodology these invariants come from in The agentic delivery method. The MCP server in this post is from the PickNDeal case study (Phase 9).