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.
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.
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:
// 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 onAuthorizationrecords 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.