Authenticate with API keys
Machine callers rarely carry a user session. Give each calling service its own
API key, store only the key’s digest, and verify it in the authenticate hook
of serveMcpHttp.
This recipe is the upgrade path from the static bearer token in
support-engine.
That example compares one literal token, so every caller shares one identity and
rotation means a restart. Here each key identifies one service, keys rotate
without downtime, and the stored value is useless if it leaks.
There is no issuer, no JWKS, and no expiry to check: for token-shaped credentials issued by an identity provider, read Authenticate with a JWT bearer token instead.
Build the verifier
Section titled “Build the verifier”-
Shape the key so the secret half stays opaque
A key is
<keyId>.<secret>. The key id is a public lookup handle; only the secret half is a credential. Storingsha256(secret)means a leaked configuration file or database dump yields nothing usable.export function hashApiKeySecret(secret: string): string {return createHash("sha256").update(secret, "utf8").digest("hex");}export function parseApiKey(credential: string,): { readonly keyId: string; readonly secret: string } | null {const match = API_KEY_PATTERN.exec(credential);const keyId = match?.[1];const secret = match?.[2];if (keyId === undefined || secret === undefined) return null;if (!KEY_ID_PATTERN.test(keyId) || !SECRET_PATTERN.test(secret)) return null;return { keyId, secret };}Issue a key once with a one-liner, keep the
hashline, and hand thekeyline to the calling service:Terminal window node -e 'const c=require("node:crypto");const id="svc_"+c.randomBytes(4).toString("hex");const s=c.randomBytes(32).toString("base64url");console.log("key: "+id+"."+s);console.log("keyId:"+id);console.log("hash: "+c.createHash("sha256").update(s).digest("hex"))' -
Declare the key set as a port
export interface ApiKeyRecord {readonly keyId: string;readonly secretHash: string;readonly serviceName: string;readonly scopes: ReadonlyArray<string>;}export interface ApiKeyRegistry {findByKeyId(keyId: string,options: { readonly signal: AbortSignal },): Promise<ApiKeyRecord | null>;}The example ships an environment-configured in-memory registry. A deployment can back the same port with a database table or a secret manager. Keep the contract exactly:
nullfor an unknown key id, and a rejection when the store is unavailable. -
Compare digests in constant time
function matchesStoredDigest(presented: Buffer, storedHex: string): boolean {const stored = DIGEST_PATTERN.test(storedHex)? Buffer.from(storedHex, "hex"): null;const comparand =stored !== null && stored.length === presented.length? stored: decoyDigest;const equal = timingSafeEqual(presented, comparand);return comparand === stored && equal;}decoyDigestis a zero-filled 32-byte buffer. A misconfigured entry — a plaintext secret where a digest belongs — costs the same as a wrong secret and still denies the request. -
Verify the presented key
async verify(credential, options) {const parsed = parseApiKey(credential);if (parsed === null) return null;const signal = AbortSignal.any([options.signal,AbortSignal.timeout(lookupTimeoutMs),]);const record = await registry.findByKeyId(parsed.keyId, { signal });const presented = createHash("sha256").update(parsed.secret, "utf8").digest();const matches = matchesStoredDigest(presented,record?.secretHash ?? decoyDigest.toString("hex"),);if (record === null || !matches) return null;return {keyId: record.keyId,serviceName: record.serviceName,scopes: [...record.scopes],};}The digest is computed and compared for an unknown key id too, so response time does not separate “no such key” from “wrong secret”. The verifier bounds its own lookup and links the request’s
AbortSignal, and a registry failure propagates instead of being reported as an invalid credential. -
Map the verified key to a
Principalexport function toPrincipal(identity: VerifiedApiKey): Principal {return Object.freeze({id: identity.serviceName,attributes: Object.freeze({keyId: identity.keyId,scopes: Object.freeze([...identity.scopes]),}),});}The principal identifies the service, not the key, so rotating a key never changes who the capability sees. The key id travels along for audit logs. The credential and its digest never leave the verifier.
-
Wire the hook at the composition root
export function createApiKeyAuthenticate(verifier: ApiKeyVerifier,): (request: McpHttpAuthenticationRequest) => Promise<Principal | null> {return async (request) => {const credential = readBearerCredential(request.headers);if (credential === null) return null;const verified = await verifier.verify(credential, {signal: request.signal,});return verified === null ? null : toPrincipal(verified);};}return serveMcpHttp(engine, {auth: {mode: "required",authenticate: createApiKeyAuthenticate(options.verifier),},});A missing, malformed, unknown, or wrong-secret key becomes
null, which the adapter answers with HTTP 401 beforeengine.invoke. A registry outage throws, which becomes a sanitized HTTP 500.
Run it
Section titled “Run it”Build the repository, then start the adapter with a configured key set:
yarn buildAPI_KEY_ENGINE_KEYS='[{"keyId":"svc_9f21c4a7","secretHash":"<hex sha256>","serviceName":"reports-worker","scopes":["identity:read"]}]' \ node examples/auth-api-key-engine/dist/mcp-http.jsCall the identity.whoami capability through MCP as identity_whoami with the
key whose secret hashes to that digest:
curl -sS http://127.0.0.1:3000/mcp \ -H 'authorization: Bearer svc_9f21c4a7.<secret>' \ -H 'accept: application/json, text/event-stream' \ -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"identity_whoami","arguments":{}}}'The result reports principalId: "reports-worker" and the keyId that proved
it. Repeat the call without the header, or with any other secret, and the
adapter answers HTTP 401.
The same verifier works on a local channel, where the composition root verifies
the configured key once and passes the principal to engine.invoke:
API_KEY_ENGINE_KEYS='[…]' \ API_KEY_ENGINE_CREDENTIAL='svc_9f21c4a7.<secret>' \ node examples/auth-api-key-engine/dist/direct.jsVerify it
Section titled “Verify it”The example’s tests cover the whole invalid-credential matrix — missing header, malformed key, unknown key id, wrong secret — plus registry failure, rotation, and the rule that no credential material reaches the principal or an error message:
yarn workspace @invokta/example-auth-api-key testRead the complete
auth-api-key-engine
example for the registry port, the constant-time comparison, and the HTTP
boundary tests.
Continue with capability authorization for the policy half, and HTTP authentication for the hook contract and the secret rules.