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.