Skip to main content

Securing API Contracts with Auth Schemes

Authentication is part of the contract, not an implementation detail bolted on afterwards. A consumer cannot call your API without knowing which credential to present, where to put it, which scope it needs, and what the response looks like when any of that is wrong. This guide extends API Contract Fundamentals & Tool Selection and covers how to express those facts in OpenAPI, enforce them from the same source, and test them.

The failure this prevents is familiar: a specification that documents every field of every response but says nothing about authorization, so each new integration begins with a week of trial and error against 401s, and every generated SDK ships with a hand-written auth shim that drifts from what the gateway actually accepts.

When to Use This Approach

Formalise the auth contract when:

  • More than one credential type reaches your API — a partner API key alongside user bearer tokens, or a service-to-service certificate alongside both.
  • You issue scoped tokens and consumers need to know which scope an operation requires before they request it.
  • You generate SDKs or mock servers from the document and want them to reflect the real auth requirements.
  • A gateway enforces authorization ahead of your service and the two need one shared source of truth.
  • Auditors or a security review need a machine-readable statement of which endpoints are public.
Declared once, enforced in three places The specification declares the schemes, the scope names and the failure responses. The gateway, the service middleware and the generated client each read that one declaration rather than carrying their own copy. openapi.yaml securitySchemes + security + 401 / 403 responses gateway rejects unauthenticated calls before they reach the service service middleware checks scopes per operation from the same document generated client knows which credential and which scope to request

Prerequisites

The examples use OpenAPI 3.1.1, Spectral 6.11 for governance rules, jose 5 for JWT verification, and Express 4. Install:

npm install jose@5 express@4
npm install -D @stoplight/spectral-cli@6.11

You need an existing document with at least one protected operation, and an identity provider whose token format you can read — the JWT claim validation guide covers what to check inside the token itself.

Step 1: Declare the Schemes Once

Every credential your API accepts becomes one entry under components.securitySchemes. Declaring them centrally is what makes the reusable-component discipline pay off for auth as well as for payloads.

components:
  securitySchemes:
    # 1. End-user access tokens issued by the identity provider.
    oauth2:
      type: oauth2
      description: Authorization-code flow for user-facing clients.
      flows:
        authorizationCode:
          authorizationUrl: https://auth.example.com/authorize
          tokenUrl: https://auth.example.com/token
          refreshUrl: https://auth.example.com/token
          scopes:
            orders:read: Read orders belonging to the authenticated user.
            orders:write: Create and modify orders.
            admin: Administrative access across all customers.

    # 2. Long-lived partner credentials, sent as a header.
    partnerKey:
      type: apiKey
      in: header
      name: X-Partner-Key
      description: Issued per partner integration. Never used by browser clients.

    # 3. Service-to-service calls inside the mesh.
    mtls:
      type: mutualTLS
      description: Client certificate issued by the internal CA.

Three rules keep this section honest. No credentials in the document — URLs and scope names only. Descriptions that say who uses it, because that is the question a new integrator actually has. And scopes named after resources and actions, not after endpoints, as covered in documenting OAuth2 scopes.

Step 2: Default to Protected

Set a root-level requirement so an operation is protected unless somebody explicitly makes it public. Getting this the wrong way round — no default, protection added per operation — means every forgotten operation is public.

# Applies to every operation that does not override it.
security:
  - oauth2: [orders:read]

paths:
  /health:
    get:
      security: []                      # explicitly public
      responses: { '200': { description: OK } }

  /orders:
    post:
      security:
        - oauth2: [orders:write]        # replaces the default, not added to it
      responses:
        '201': { description: Created }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /admin/customers/{id}/orders:
    get:
      security:
        - oauth2: [admin]
        - partnerKey: []                # OR: either scheme is sufficient

Two subtleties are worth stating explicitly, because both cause real incidents. An operation-level security array replaces the root array rather than adding to it. And multiple entries in the array are alternatives (logical OR), while multiple schemes inside one entry are all required (logical AND).

Reading the security array correctly Two list entries mean either credential is accepted; two schemes inside one entry mean both must be presented together. Mixing the two up either locks out valid callers or admits under-credentialed ones. two entries — OR - oauth2: [admin] - partnerKey: [] either credential is accepted used for "staff or partner integration" one entry, two schemes — AND - partnerKey: [] mtls: [] both must be presented used for high-value partner writes

Step 3: Document the Failure Responses

An auth contract with no documented failures is half a contract. Define the two responses once and reference them everywhere, reusing the problem document schema the rest of your API already returns.

components:
  responses:
    Unauthorized:
      description: No valid credential was presented.
      headers:
        WWW-Authenticate:
          schema: { type: string }
          description: 'e.g. Bearer realm="api", error="invalid_token"'
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://docs.example.com/errors/unauthenticated
            title: Authentication required
            status: 401
            code: UNAUTHENTICATED
    Forbidden:
      description: The credential is valid but lacks the required scope.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://docs.example.com/errors/insufficient-scope
            title: Insufficient scope
            status: 403
            code: INSUFFICIENT_SCOPE
            detail: This operation requires the orders:write scope.

Naming the missing scope in detail is the single most useful thing you can put in a 403. It converts a support conversation into a token request.

Step 4: Enforce From the Same Document

The way auth drifts is that the specification says orders:write and the middleware checks order.write. Remove the possibility by reading the requirement from the document at startup.

// auth/from-spec.ts — build the scope table from the contract itself
import type { RequestHandler } from "express";
import { readFileSync } from "node:fs";
import { parse } from "yaml";

const spec = parse(readFileSync("openapi.yaml", "utf8"));

// "GET /orders/{id}" -> ["orders:read"]
const required = new Map<string, string[]>();
for (const [path, item] of Object.entries<any>(spec.paths)) {
  for (const method of ["get", "post", "put", "patch", "delete"]) {
    const op = item[method];
    if (!op) continue;
    const sec = op.security ?? spec.security ?? [];
    const oauth = sec.find((entry: any) => "oauth2" in entry);
    required.set(`${method.toUpperCase()} ${path}`, oauth?.oauth2 ?? []);
  }
}

export const requireScopes: RequestHandler = (req, res, next) => {
  const key = `${req.method} ${req.route?.path ?? req.path}`;
  const needed = required.get(key) ?? [];
  if (needed.length === 0) return next();                 // documented as public
  const granted: string[] = res.locals.token?.scope?.split(" ") ?? [];
  const missing = needed.filter((s) => !granted.includes(s));
  if (missing.length === 0) return next();
  res.status(403).type("application/problem+json").json({
    type: "https://docs.example.com/errors/insufficient-scope",
    title: "Insufficient scope",
    status: 403,
    code: "INSUFFICIENT_SCOPE",
    detail: `This operation requires: ${missing.join(", ")}.`,
  });
};

Why this works: a scope renamed in the document changes the enforcement on the next deploy, and a scope renamed in code without touching the document has no effect at all — which is the safe direction for the mistake to fail in.

Step 5: Lint the Auth Surface

Two rules catch the mistakes that matter:

# .spectral.yaml
extends: ["spectral:oas"]
rules:
  # An operation that opts out of auth must say so deliberately, in prose.
  public-operations-documented:
    description: An operation with security: [] must explain why it is public.
    given: "$.paths[*][get,post,put,patch,delete][?(@.security && @.security.length == 0)]"
    severity: error
    then:
      field: description
      function: truthy

  # Protected operations must document how they fail.
  protected-operations-document-401:
    description: Operations requiring credentials must document a 401 response.
    given: "$.paths[*][get,post,put,patch,delete][?(@.security)].responses"
    severity: warn
    then:
      field: "401"
      function: truthy

Security Scheme Reference

Scheme type Where the credential travels Declares Typical use
http / bearer Authorization: Bearer … bearerFormat JWT access tokens
http / basic Authorization: Basic … nothing further legacy internal tools
apiKey header, query or cookie in, name partner integrations
oauth2 via the declared flow flows and scopes user-facing clients
openIdConnect via discovery openIdConnectUrl when the IdP publishes metadata
mutualTLS TLS handshake nothing further service-to-service

Note what none of them declare: the credential itself. A tokenUrl is public information; a token is not.

Two checks, in the right order The gateway answers whether the caller is who they claim to be and rejects with 401; the service answers whether the caller may perform this operation and rejects with 403; only then does the handler run. request authenticate who are you? — 401 authorise may you? — 403 handler Both checks read the same declaration; only their answers, and their status codes, differ.

Verification

# No credential at all: 401, with a WWW-Authenticate header.
curl -sD - -o /dev/null https://api.example.com/v2/orders
# HTTP/2 401
# www-authenticate: Bearer realm="api", error="invalid_token"

# Valid token, wrong scope: 403 naming the scope that is missing.
curl -s -H "authorization: Bearer $READ_ONLY_TOKEN" \
     -XPOST https://api.example.com/v2/orders -d '{}' | jq -r .detail
# This operation requires: orders:write.

# The public endpoint really is public.
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/v2/health
# 200

# Every path in the document is either protected or explicitly public.
yq '.paths | to_entries[] | .value | to_entries[] | select(.value.security == null)' openapi.yaml
# (empty — nothing relies on an accidental default)

Troubleshooting

The gateway and the service disagree about which endpoints are public

They are reading different sources. Generate the gateway’s route configuration from the specification, or at minimum add a CI check that compares the gateway’s public-route list with the operations declaring security: []. Two hand-maintained lists will diverge within a quarter.

Consumers report intermittent 401s after a deploy

Usually key rotation without an overlap. The identity provider publishes a new signing key, the service caches the old key set, and requests fail until the cache expires. Fetch the key set with a short cache and a fallback refresh on an unknown key ID, and never pin a single key.

A 403 tells the caller nothing actionable

The response is being generated by a generic error handler that has lost the context. Produce the 403 in the scope-checking middleware where the missing scope is known, and put its name in detail. A 403 that says only “forbidden” costs a support round trip every time.

The mock server accepts anything, so consumers build against a false picture

That is usually acceptable — enforcing tokens in a mock makes local development painful — but it must be balanced by at least one contract test hitting the real 401 and 403 paths, as described in contract testing authenticated endpoints.

Scope names have proliferated

Symptom of scopes defined per endpoint. Consolidate to resource-and-action pairs, map the old names to the new ones at the token-issuing layer during a migration window, and delete the old names once no issued token still carries them.

Keeping the Auth Contract Honest Over Time

An auth contract decays differently from a payload contract. Nobody notices when it drifts, because the failure mode is silence: an endpoint that should be protected simply is not, and every request succeeds. Four habits keep it honest.

Review the public list, not the protected list. The interesting question is never “which endpoints require a token” — it is “which endpoints do not”. Generate that list from the document on every release and put it in front of a human. It is usually short enough to read in thirty seconds, and an unexpected entry is always worth investigating.

Treat a scope rename as a breaking change. A scope is part of the contract a caller integrated against, and renaming one invalidates every issued token that carries the old name. Run it through the same deprecation window as a field rename: accept both names, migrate the issuer, then remove the old one when no live token can still carry it.

Test the negative cases in CI, not by hand. The authenticated happy path gets tested because it is the path features are built on. The unauthenticated and under-scoped paths get tested only if somebody writes those tests deliberately, and they are the paths that matter for security. One test per protected operation asserting a 401 without credentials is cheap and catches the whole class of regression.

Watch the gap between the gateway and the service. Where a gateway authenticates and a service authorises, the two hold copies of the same requirement. Generate one from the other, or add a check that compares them. Two hand-maintained copies diverge within a quarter, and the divergence is invisible until an endpoint the gateway believes is public is reachable without a token.

What the Contract Cannot Express

It is equally useful to know where the document stops. OpenAPI can say that a bearer token is required and which scope it needs. It cannot say that a caller may only read their own orders, that an admin scope is limited to one tenant, or that a partner key is rate-limited to a thousand requests an hour.

Those are authorisation rules about data, not about the operation, and they belong in the service — enforced in the query rather than in a middleware. The contract’s job is to tell the caller which credential to present and what the failure looks like; deciding which rows that credential may see is application logic, and no security scheme declaration will do it for you.

The practical consequence is that a passing contract test proves the operation is reachable with the right scope, not that the data returned is scoped correctly. Row-level authorisation needs its own tests, written against the domain layer, and those tests are where a tenant-isolation bug is actually caught.

Auth in Generated Clients and Mocks

The two artifacts generated from the document handle authentication differently, and both behaviours are worth knowing before an integrator discovers them.

Generated clients usually emit a configuration hook rather than an implementation: a place to supply a token, and per-operation knowledge of which scheme applies. That is the useful half. What they do not emit is token acquisition — the authorization-code round trip, the refresh handling, the retry on expiry — because none of it is expressible in the document. Expect to ship a thin wrapper around the generated client that owns the credential lifecycle, and document that wrapper as part of the SDK rather than leaving each consumer to write it.

Mock servers generally ignore security schemes entirely. A request with no credential gets the documented success response, which is convenient for development and misleading if a consumer concludes the endpoint is public. Two mitigations are worth the effort: state in the mock’s documentation that it does not enforce authentication, and cover the 401 and 403 paths in contract tests against the real service so the behaviour is verified somewhere.

A third artifact is worth generating deliberately: the list of operations declaring security: []. It is the security review’s actual question, it takes one query against the document, and it is far more useful in a review than the document itself.

One Rule Worth Adopting Immediately

If only one practice from this guide survives, make it the root-level default. An API where every operation is protected unless it explicitly opts out cannot accidentally publish an endpoint, and the opt-outs are a short list a human can read. Every other control here is an improvement on top of that one; without it, each is guarding a surface with unknown holes in it.

Frequently Asked Questions

Why document authentication in the contract at all — is it not an infrastructure concern?

Because a caller cannot integrate without knowing which credential to present, which scope it needs, and what happens when it is missing. An undocumented auth requirement turns every new integration into a support ticket, and it makes generated clients and mock servers wrong by construction.

What is the difference between a 401 and a 403 in an API contract?

401 means the request carried no valid credential — authenticate and try again. 403 means the credential was valid but does not permit this operation — retrying with the same token is pointless. Documenting both, with distinct error codes, saves consumers from writing retry loops that can never succeed.

Should scopes be per endpoint or per resource?

Per resource and action, not per endpoint. orders:read and orders:write scale as the API grows; a scope per operation produces a token with forty scopes that nobody can reason about. Group operations that a caller would always be granted together.

Can OpenAPI describe mutual TLS?

Yes, using a security scheme of type mutualTLS in OpenAPI 3.1. It carries no configuration of its own — the certificate requirements live in your infrastructure — but declaring it tells consumers a client certificate is required and lets tooling stop pretending a bearer token would work.

How do I keep secrets out of the specification?

Declare the scheme, never a credential. Token URLs, scope names and header names belong in the document; keys, secrets and certificates never do. A lint rule that rejects anything resembling a credential in the spec is worth adding early.

Does a mock server need to enforce auth?

Usually not, and forcing it to makes local development painful. What matters is that the mock rejects requests the contract forbids in shape, and that at least one contract test exercises the real 401 and 403 paths against the real service.