Modeling API Key and mTLS Auth in OpenAPI
Problem: your OAuth2 flow is documented beautifully, but the partner integrations authenticate with a long-lived key and a client certificate, and the specification says nothing about either. Integrators discover the requirements from a PDF, a support email, or a 401. This guide is part of Securing API Contracts with Auth Schemes and covers the two schemes that machine-to-machine integrations actually use.
Root Cause: Two Schemes That Look Like Infrastructure
API keys and client certificates feel like operational concerns rather than contract concerns, so they end up documented outside the contract — or not at all. But from a caller’s perspective they are exactly as contractual as a request body: without knowing the header name or that a certificate is needed, the integration cannot be written.
Step 1: Declare the apiKey Scheme Precisely
components:
securitySchemes:
partnerKey:
type: apiKey
in: header # header | query | cookie — header is the only good answer
name: X-Partner-Key # exact, case-insensitive on the wire but exact in docs
description: |
Issued per partner integration through the partner console. Each partner may
hold two active keys at a time so rotation never requires a synchronised
switch. Keys are 43-character base64url strings prefixed with `pk_live_`
or `pk_test_`; the prefix determines which environment they address.
Keys are not scoped — authorisation is derived from the partner record.
Three things this description does that a bare declaration does not: it tells the integrator where keys come from, it states that two keys can be live at once (which is the fact they need for rotation), and it says what the key does not carry — no scopes — so nobody looks for a scope parameter that does not exist.
Avoid in: query unless a legacy integration forces it. A key in a query string is written to access logs on every hop, appears in browser history, and leaks through referrer headers. If you must support it, document it as deprecated and give it a shorter rotation period.
Step 2: Declare mutualTLS Honestly
OpenAPI 3.1 added a mutualTLS scheme type with no configuration fields at all, which is exactly right: the certificate requirements are enforced by the TLS terminator, not by your application.
components:
securitySchemes:
mtls:
type: mutualTLS
description: |
A client certificate issued by the Example Internal CA is required on the
TLS connection. The certificate's Common Name must match the registered
partner identifier. Certificates are valid for 12 months; the platform
team issues replacements 30 days before expiry. Requests without a valid
certificate are rejected at the edge and never reach the application, so
they produce a TLS handshake failure rather than an HTTP status.
That last sentence is the one integrators need most. A failed handshake looks nothing like a 401 — it surfaces as a connection error in their HTTP client — and saying so in the contract saves hours of debugging on both sides.
Step 3: Combine Them Where Both Are Required
paths:
/partners/settlements:
post:
summary: Submit a settlement batch
security:
- partnerKey: []
mtls: [] # one entry, two schemes: BOTH are required
responses:
'202': { description: Batch accepted for processing. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/partners/status:
get:
summary: Integration health
security:
- partnerKey: [] # separate entries: EITHER is sufficient
- mtls: []
The distinction is easy to get backwards and the consequences are asymmetric: writing two entries where you meant one entry with two schemes silently downgrades a high-value endpoint to single-factor authentication.
Step 4: Distinguish the Failure Cases
A partner debugging an integration needs to know which credential is wrong. Three cases are worth separating, all returning 401 but with distinct detail and code values:
// auth/partner-key.ts
export const partnerKey: RequestHandler = async (req, res, next) => {
const presented = req.get("x-partner-key");
if (!presented) return deny(res, "MISSING_KEY", "no X-Partner-Key header was presented");
const record = await keys.lookup(presented); // constant-time comparison inside
if (!record) return deny(res, "UNKNOWN_KEY", "the presented key is not recognised");
if (record.revokedAt) {
return deny(res, "REVOKED_KEY",
`this key was revoked on ${record.revokedAt.toISOString().slice(0, 10)}`);
}
res.locals.partner = record.partner;
next();
};
Telling a caller that their key was revoked, and when, is not an information leak — they hold the key already. Telling them nothing turns a five-minute fix into a support ticket.
Verification
# Both credentials: accepted.
curl -s -o /dev/null -w '%{http_code}\n' \
--cert partner.crt --key partner.key \
-H "X-Partner-Key: $PARTNER_KEY" \
-XPOST https://api.example.com/v2/partners/settlements -d '{}'
# 202
# Certificate, no key: a 401 that names what is missing.
curl -s --cert partner.crt --key partner.key \
-XPOST https://api.example.com/v2/partners/settlements -d '{}' | jq -r '.code, .detail'
# MISSING_KEY
# no X-Partner-Key header was presented
# Key, no certificate: the connection itself fails — there is no HTTP response.
curl -s -o /dev/null -w '%{http_code}\n' \
-H "X-Partner-Key: $PARTNER_KEY" \
-XPOST https://api.example.com/v2/partners/settlements -d '{}'
# 000 (curl: (56) OpenSSL SSL_read: alert certificate required)
# The either-or endpoint accepts a key alone.
curl -s -o /dev/null -w '%{http_code}\n' \
-H "X-Partner-Key: $PARTNER_KEY" https://api.example.com/v2/partners/status
# 200
The 000 in the third check is the observable proof that the certificate is enforced at the edge rather than in the application — and it is exactly what the scheme’s description warned the integrator to expect.
Edge Cases and Caveats
- Certificate expiry with no warning path. Because the failure is a handshake error, a partner whose certificate expired overnight sees connection failures with no HTTP body to read. Monitor certificate expiry yourself and alert the partner before the date; the contract cannot do this for you.
- Key comparison timing. Look keys up by a hash and compare with a constant-time function. A naive string comparison against a database result is a timing oracle, however small.
- Keys in mock servers. A Prism mock will not enforce the key, which is fine for development but means the 401 path is untested unless a contract test covers it against the real service.
- Load balancers and client certificates. If TLS terminates at a load balancer, the application never sees the certificate unless the balancer forwards the subject in a header. Verify that header cannot be spoofed by a direct caller — strip it at the edge unconditionally before setting it.
- Multiple keys per partner. Supporting two live keys is what makes rotation safe; supporting unlimited keys makes revocation meaningless. Two, with an expiry on the older one, is the workable number.
Documenting the Issuance Process
Neither scheme means much to an integrator without knowing how to obtain the credential, and that process is not part of the OpenAPI document. It belongs in the description field, in one paragraph, with a link.
For an API key, the facts that matter are: where keys are issued, how many can be active at once, what the prefix means, and how to rotate. Two of those are contractual — the number of concurrent keys determines whether rotation requires downtime, and the prefix tells an engineer at a glance whether they have deployed a test key to production.
For a client certificate, the facts are: which certificate authority issues it, what the subject must contain, how long it is valid, and how a replacement is obtained before expiry. The last one is the most valuable, because certificate expiry produces a failure with no HTTP response to read.
Frequently Asked Questions
Should an API key go in a header or a query parameter?
A header. Query parameters land in access logs, browser history, referrer headers and error reports, so a key sent that way should be treated as compromised from the moment it is used. If a legacy integration requires the query form, document it, rotate those keys more aggressively, and plan its removal.
What can OpenAPI actually say about mutual TLS?
That a client certificate is required, and nothing more. The mutualTLS scheme type has no configuration fields, because certificate issuance, trust chains and revocation live in infrastructure. Declaring it still matters: it tells consumers and tooling that a bearer token alone will not work.
Can I require an API key and mTLS together?
Yes — put both schemes inside a single security entry and both must be presented. This is a common shape for high-value partner writes, where the certificate identifies the organisation and the key identifies the specific integration.
How do I document key rotation in the contract?
The contract declares the header, not the key’s lifecycle. Document rotation in the description field with a link to the operational guide, and make the API accept two valid keys per partner during an overlap so rotation never requires a synchronised switch.
Does an API key need its own error responses?
It shares the 401 and 403 responses with every other scheme. What differs is the detail message: a missing key, an unknown key and a revoked key are three distinguishable situations, and telling a partner which one applies saves a support round trip.