Practice: Machine-to-machine API auth: keys, client credentials, mTLS and signed webhooks
Generated by Claude (Fable 5.1) by hand from the identity and integration ops spike, Scout learning center on 2026-10-14.
Main tool: Keycloak and Python (requests, hmac) · Keycloak gives a local authorization server for the client-credentials, introspection and revocation steps, and the standard library's hmac and http.server are enough to write a correct webhook receiver and attack it with a tampered and a stale delivery; nothing in the path needs a framework, so every byte that is signed and compared is visible. · Alternatives: node-oidc-provider for the OAuth steps without Docker; Mockoon or WireMock as a scriptable webhook receiver or a mock partner API with auth failures; the step CLI for a local CA, client certificates and 'step oauth' for client credentials from the shell; navikt/mock-oauth2-server as a second issuer for service-to-service tests.
Every step links into the tool's own documentation; links were checked on 2026-10-14 · version 1
Setup (one time)
1. Have Keycloak 26.4 running on 127.0.0.1:18080 with the realm from the OAuth practice path and its confidential client m2m-batch (service accounts enabled, a generated secret you keep in an environment variable).
The machine grant, introspection and revocation steps run against this client.
Explained in: Keycloak: Getting started with Docker (www.keycloak.org), checked 2026-10-14
Done when: A client-credentials token request for m2m-batch answers 200.
2. Create a scratch Python virtual environment with requests and PyJWT (crypto extra); the webhook receiver needs only the standard library.
Everything in this path is a few lines of Python; no framework is needed to see the mechanics.
Explained in: GitHub Docs: Validating webhook deliveries (docs.github.com), checked 2026-10-14
Done when: python -c 'import requests, jwt, hmac, hashlib' exits 0.
Steps
1. Request a client-credentials token for m2m-batch with the client id and secret as HTTP Basic authentication; then repeat with a wrong secret.
The machine grant and its one negative case; both ran this way in the learning center's test run.
Explained in: RFC 6749 section 4.4: Client Credentials Grant (datatracker.ietf.org), checked 2026-10-14
Done when: The first answers 200 with a Bearer token, expires_in and no refresh token; the second answers 401 with unauthorized_client or invalid_client.
2. Introspect the token at the introspection endpoint, revoke it at the revocation endpoint, and introspect it again.
Revocation is the only way to end a JWT access token before exp, and only resource servers that introspect notice; seeing the flip makes the limitation concrete.
Explained in: RFC 7009: OAuth 2.0 Token Revocation (datatracker.ietf.org), checked 2026-10-14
Done when: active true with client_id m2m-batch, then active false after revocation.
3. Decode the access token's payload without verifying and read azp, aud, typ, exp and preferred_username; compare with what a resource server would check.
A machine token's shape differs from a user's; reading one once prevents the audience mistake in the resource server.
Explained in: Okta Developer: Implement OAuth for Okta - Client Credentials flow (developer.okta.com), checked 2026-10-14
Done when: You can say which claim names the client, which names the audience, and why the subject is a service account rather than a person.
4. Write a webhook receiver on 127.0.0.1 (about 40 lines, standard library) that reads the raw body, rejects a missing or out-of-tolerance X-Webhook-Timestamp (five minutes), computes HMAC-SHA256 over timestamp.body with a secret from the environment, compares it in constant time with X-Webhook-Signature, and answers 200 or 400 with the reason.
This is the receiver every integration that accepts events needs; the GitHub page shows the same check in several languages.
Explained in: GitHub Docs: Validating webhook deliveries (docs.github.com), checked 2026-10-14
Done when: The receiver starts and answers 400 'stale or missing timestamp' to a bare POST.
5. Send four deliveries from a small sender: a valid one, one whose body was changed after signing, one whose timestamp is six minutes old, and one signed with a different secret.
The three negative cases are the test suite of a webhook receiver; Stripe's page explains the tolerance you just enforced.
Explained in: Stripe: Receive Stripe events in your webhook endpoint (docs.stripe.com), checked 2026-10-14
Done when: 200 ok, then 400 signature mismatch, 400 stale or missing timestamp, 400 signature mismatch; this path produced exactly those four answers.
6. Read the Standard Webhooks specification and rewrite the sender's headers to its webhook-id, webhook-timestamp and webhook-signature (v1, base64 over id.timestamp.body) and the receiver to match; add a cache of recent webhook-ids so a replay inside the tolerance is also rejected.
Adopting the published scheme means the customer can use an existing library instead of reading your document.
Explained in: Standard Webhooks specification (github.com), checked 2026-10-14
Done when: The valid delivery passes, the same delivery sent twice is rejected the second time, and the receiver would work with any Standard Webhooks sender library.
7. Switch the Keycloak client's authenticator to Signed JWT, register a public key (JWKS URL or certificate), and request a token with a client_assertion signed by the matching private key (RFC 7523) instead of the secret. (Read, not run by the learning center.)
private_key_jwt is the client authentication to ask for first at any customer whose server supports it; one run shows how little the client changes.
Explained in: RFC 7523: JWT Profile for OAuth 2.0 Client Authentication and Authorization Grants (datatracker.ietf.org), checked 2026-10-14
Done when: The token endpoint answers 200 for a fresh assertion and rejects one whose exp has passed or whose jti was already used (Keycloak answers 401 with invalid_client or unauthorized_client, as it does for a wrong secret).
8. Read RFC 8705 and write down, for a customer who requires mutual TLS, the three questions (who issues the client certificates, where TLS terminates, whether the gateway forwards the certificate or the verification result) and what certificate-bound tokens add. (Read only.)
mTLS work is certificate lifecycle work; the questions decide its size before anyone writes code.
Explained in: RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens (datatracker.ietf.org), checked 2026-10-14
Done when: A half-page note with the three questions and the cnf claim explained.
9. Write the credential table for this integration: each secret or key, where it lives, who owns it, its rotation period, the rotation procedure with overlap, and the revocation procedure; check that none of them appears in a file you could commit.
This is the table a customer's security review asks for; Stripe's key model is a good template for the columns.
Explained in: Stripe: API keys (docs.stripe.com), checked 2026-10-14
Done when: The table has one row per credential used in the steps above (client secret, webhook secret, private key) and git status shows no secret in the tree.
Back to Machine-to-machine API auth: keys, client credentials, mTLS and signed webhooks