Skip to main content

Contract Testing Authenticated Endpoints with Pact

Symptom: every consumer contract you have covers unauthenticated endpoints, because the moment a real endpoint needs a bearer token the test either embeds a token that expires, or the team gives up and skips contract testing for that endpoint entirely. This guide is part of Securing API Contracts with Auth Schemes and shows the pattern that makes protected endpoints as testable as public ones.

Root Cause: Credentials Are Not Part of the Contract

The mistake is treating the token as data the contract should describe. It is not. What the contract describes is that the consumer sends a credential and the provider requires one; the specific token is an environment detail that differs between the consumer’s test run, the provider’s verification run, and production.

Separating those two facts is the whole technique. The consumer records the shape of the credential; the provider supplies the value at replay time.

The credential is swapped between recording and replay The consumer test records a matcher on the Authorization header rather than a value; the pact file therefore contains no token; the provider's request filter mints a short-lived test token and injects it before the interaction is replayed. consumer test records a matcher: "Bearer .+" pact file no real token anywhere safe to publish provider verification request filter mints a token valid for this run only what the contract actually asserts a credential is sent, and the provider requires one — not which token

Step 1: Match the Credential, Do Not Record It

On the consumer side, use a matcher. The examples use Pact JS 13.

// consumer/orders.pact.test.ts
import { PactV3, MatchersV3 } from "@pact-foundation/pact";
const { like, regex } = MatchersV3;

const provider = new PactV3({ consumer: "web-app", provider: "orders-api" });

describe("fetching an order", () => {
  it("returns the order for an authorised caller", async () => {
    await provider
      .given("an order ord_42 exists and the caller may read it")
      .uponReceiving("a request for order ord_42")
      .withRequest({
        method: "GET",
        path: "/v2/orders/ord_42",
        headers: {
          // The SHAPE is the contract. The value is environment-specific,
          // so it must never be recorded verbatim.
          Authorization: regex(/^Bearer .+$/, "Bearer test-token-not-a-real-credential"),
        },
      })
      .willRespondWith({
        status: 200,
        headers: { "Content-Type": "application/json" },
        body: like({ id: "ord_42", status: "paid", total: { amount: 4200, currency: "EUR" } }),
      })
      .executeTest(async (mock) => {
        const client = new OrdersClient(mock.url, () => "Bearer whatever-the-app-uses");
        const order = await client.getOrder("ord_42");
        expect(order.id).toBe("ord_42");
      });
  });
});

Two details matter. The example value in the regex matcher is what the mock server sends back to the consumer during its own test, so it should be obviously fake — a value someone grepping for leaked secrets will not flag. And the provider state names an authorisation situation, not a token.

Step 2: Inject a Real Credential at Verification Time

The provider’s verifier rewrites the header before each interaction is replayed. This is where a valid credential enters the process, and it never touches the pact file.

// provider/verify.pact.ts
import { Verifier } from "@pact-foundation/pact";
import { SignJWT, importPKCS8 } from "jose";

// A key pair used only by the test suite; the service is configured to trust
// its public half through a local JWKS endpoint started alongside the app.
const testKey = await importPKCS8(process.env.PACT_TEST_PRIVATE_KEY!, "RS256");

async function mintToken(scopes: string[]): Promise<string> {
  return new SignJWT({ scope: scopes.join(" "), org_id: "11111111-1111-1111-1111-111111111111" })
    .setProtectedHeader({ alg: "RS256", kid: "pact-test" })
    .setIssuer("https://auth.test.local/")
    .setAudience("https://api.example.com/v2")
    .setSubject("usr_pact")
    .setExpirationTime("5m")            // short: this token exists for one run
    .sign(testKey);
}

await new Verifier({
  provider: "orders-api",
  providerBaseUrl: "http://127.0.0.1:3000",
  pactBrokerUrl: process.env.PACT_BROKER_URL,
  consumerVersionSelectors: [{ deployedOrReleased: true }, { mainBranch: true }],

  // Runs once per interaction, immediately before it is replayed.
  requestFilter: async (req, _res, next) => {
    // The scopes come from the state the interaction declared, so a
    // "without the write scope" state genuinely produces a weaker token.
    const scopes = req.headers["x-pact-scopes"]
      ? String(req.headers["x-pact-scopes"]).split(",")
      : ["orders:read", "orders:write"];
    req.headers["authorization"] = `Bearer ${await mintToken(scopes)}`;
    next();
  },

  stateHandlers: {
    "an order ord_42 exists and the caller may read it": async () => {
      await db.orders.insert({ id: "ord_42", status: "paid", total: 4200, currency: "EUR" });
    },
    "a caller without the orders:write scope": async () => {
      // The state handler records what the request filter should mint.
      process.env.PACT_NEXT_SCOPES = "orders:read";
    },
  },
}).verifyProvider();

Why this works: the filter runs inside the verification process, so the token is alive for seconds and never leaves it. Rotating the test key pair breaks nothing outside the test suite.

Step 3: Cover the Failure Paths the Consumer Handles

A consumer contract exists to protect consumer behaviour. Record a 401 interaction only if the consumer does something when it receives one — refresh the token, redirect to login, open a circuit breaker.

it("refreshes the token and retries once on a 401", async () => {
  await provider
    .given("the presented token has expired")
    .uponReceiving("a request with an expired token")
    .withRequest({
      method: "GET",
      path: "/v2/orders/ord_42",
      headers: { Authorization: regex(/^Bearer .+$/, "Bearer expired-test-token") },
    })
    .willRespondWith({
      status: 401,
      headers: { "Content-Type": "application/problem+json" },
      body: like({ type: "https://docs.example.com/errors/unauthenticated",
                   title: "Authentication required", status: 401, code: "UNAUTHENTICATED" }),
    })
    .executeTest(async (mock) => {
      const refresh = jest.fn().mockResolvedValue("Bearer fresh");
      const client = new OrdersClient(mock.url, () => "Bearer expired", { onUnauthorized: refresh });
      await expect(client.getOrder("ord_42")).rejects.toThrow();
      expect(refresh).toHaveBeenCalledTimes(1);      // the behaviour under test
    });
});

The assertion is on the consumer’s reaction, not on the status code. That is what makes the interaction worth the provider obligation it creates.

Record the interaction only when the consumer reacts to it Three candidate interactions are judged: the authorised success and the expired-token refresh both drive real consumer behaviour and belong in the contract; a generic 403 that the consumer only logs does not. Does the consumer do something different because of this response? 200 with an order body renders three specific fields record it — those fields are the contract 401 on an expired token triggers a refresh and one retry record it — the retry is real behaviour 403 the consumer only logs no branch, no retry, no message skip it — a provider obligation for nothing 500 from the provider generic error handling skip it — not a contract, an incident

Step 4: Verify Against a Local Issuer

Verification must not depend on a live identity provider. Start a tiny JWKS endpoint alongside the service under test and point the service’s issuer configuration at it:

A local issuer keeps the build self-contained The verification process mints tokens with a test key and serves the public half from a local JWKS endpoint the service under test trusts, so the build depends on no external system and no long-lived credential. the CI job — nothing leaves it verifier mints 5-minute tokens local JWKS endpoint serves the public half service under test trusts only this issuer No network dependency, and no credential that outlives the run.
// test/local-issuer.ts — serves the public half of the test key pair
import http from "node:http";
import { exportJWK, importSPKI } from "jose";

export async function startLocalIssuer(port = 4599) {
  const pub = await importSPKI(process.env.PACT_TEST_PUBLIC_KEY!, "RS256");
  const jwk = { ...(await exportJWK(pub)), kid: "pact-test", alg: "RS256", use: "sig" };
  const server = http.createServer((req, res) => {
    if (req.url === "/.well-known/jwks.json") {
      res.setHeader("content-type", "application/json");
      return res.end(JSON.stringify({ keys: [jwk] }));
    }
    res.statusCode = 404; res.end();
  });
  await new Promise<void>((r) => server.listen(port, r));
  return server;
}
# The service under verification trusts the local issuer, nothing else.
AUTH_ISSUER=https://auth.test.local/ \
AUTH_JWKS_URL=http://127.0.0.1:4599/.well-known/jwks.json \
AUTH_AUDIENCE=https://api.example.com/v2 \
npm run start:test &
npm run test:pact:provider

Verification

$ npm run test:pact:provider

Verifying a pact between web-app and orders-api
  a request for order ord_42
    given an order ord_42 exists and the caller may read it
      returns a response which
        has status code 200 (OK)
        has a matching body (OK)
  a request with an expired token
    given the presented token has expired
      returns a response which
        has status code 401 (OK)
        has a matching body (OK)

2 interactions, 0 failures

And the check that matters most for safety — nothing resembling a credential reached the published contract:

# No JWTs, no long opaque strings in any pact file.
grep -rEc 'eyJ[A-Za-z0-9_-]{10,}' pacts/ || echo "no tokens in pact files"
# no tokens in pact files

jq -r '.interactions[].request.headers.Authorization' pacts/web-app-orders-api.json
# Bearer test-token-not-a-real-credential

Edge Cases and Caveats

  • Matchers on the header value. A like() matcher on Authorization records the example value as the expectation shape, which is fine, but a plain string records it as an exact requirement — and then the provider must send that exact token. Always use a regex or type matcher.
  • Provider state leakage between interactions. If a state handler sets scopes through an environment variable, reset it in a teardown handler; otherwise a later interaction inherits a weaker token and fails for the wrong reason.
  • Token expiry during long verification runs. A five-minute token and a fifteen-minute verification run means later interactions fail with a 401 that looks like a contract break. Mint per interaction, in the request filter, rather than once per run.
  • Bi-directional testing. When comparing a consumer contract against a provider specification rather than a running service, the credential question disappears entirely — there is nothing to authenticate against, which is one of the quieter advantages of bi-directional contract testing.
  • Secret scanning in CI. Add the pact output directory to your secret scanner’s watched paths. It costs nothing and catches the day somebody replaces a matcher with a real token to “just make it pass”.

Keeping the Test Credentials Separate From the Real Ones

The arrangement above works because the test key pair is genuinely separate from anything that grants real access. Two boundaries keep it that way.

The service under verification trusts the local issuer only in its test configuration. If the same configuration path is used in any deployed environment, a token minted by the test key would be accepted in production — which is exactly the failure the separation exists to prevent. Make the test issuer configuration explicit and impossible to enable accidentally.

The test key pair itself is not a secret worth protecting, provided the first boundary holds. That is a feature: it can live in the repository, rotate freely, and appear in logs without consequence. If protecting it starts to feel necessary, the first boundary has leaked.

Frequently Asked Questions

Should a real token ever appear in a pact file?

No. A pact file is published to a broker, cached in CI artifacts and often readable by several teams. Match the Authorization header on type or on a regex, and let the verifier inject a freshly minted test credential at replay time.

How does the provider get a valid token during verification?

Through a request filter that runs before each interaction is replayed. It mints or fetches a short-lived test token, then rewrites the Authorization header on the incoming replay request.

Should the consumer contract include the 401 case?

Include it only if the consumer has real behaviour for it — a token refresh, a re-login prompt, a circuit breaker. A contract exists to protect consumer behaviour, so an interaction the consumer does not handle adds a provider obligation for nothing.

How do I test the 403 case without a second real identity?

Use a provider state that describes the authorisation situation rather than the credential: “a token without the orders:write scope”. The provider’s state handler decides how to produce that situation, which may be a differently minted test token.

What if the provider verifies tokens against a live identity provider?

Point it at a local issuer for verification runs — a test key pair and a static JWKS endpoint. Verifying against a live provider makes the build depend on an external system and tempts people to embed long-lived tokens in CI.