Skip to main content

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.

Three schemes, three things being identified A bearer token identifies a user or a session, an API key identifies a specific integration, and a client certificate identifies the calling organisation and its transport. They answer different questions and are often combined. bearer token identifies: a user session lifetime: minutes carries: scopes, claims verified by signature API key identifies: an integration lifetime: months carries: nothing — it is a lookup verified by a table lookup client certificate identifies: an organisation lifetime: a year carries: a subject and a chain verified during the handshake

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.

The four cases on a both-required endpoint A matrix of certificate present or absent against key present or absent: only the both-present case succeeds, and the three failures are distinguishable by where they occur and what they return. valid client certificate no client certificate valid X-Partner-Key 202 Accepted the only success handshake fails no HTTP status at all missing or unknown key 401 "no partner key presented" handshake fails the key is never read

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:

Three key failures, three remedies A missing key, an unknown key and a revoked key all return 401 but carry distinct codes, so the partner knows whether to add a header, check which key they deployed, or request a new one. situation code what the partner does next no X-Partner-Key header MISSING_KEY add the header — a client bug key not recognised UNKNOWN_KEY check which key the deploy carries key revoked on a known date REVOKED_KEY request a replacement — no guessing
// 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.