Verifying JWT Webhooks

How to verify that a JWT webhook was sent by Moneyhub, and decode it.

When your client uses the JWT webhook format, each webhook is signed by Moneyhub. Verify every webhook before trusting its contents, so that only messages from Moneyhub are accepted by your system.

Use a well-maintained JWT library to verify and decode the token in one step. Don't decode the token without verifying it.

What to check

CheckExpected value
SignatureValid for the key in the Moneyhub JWKS whose kid matches the kid in the JWT header
AlgorithmRS256. Reject any other algorithm, including none
isshttps://identity.moneyhub.co.uk
audThe client ID of your API client
iatRecent. We suggest rejecting tokens issued more than 10 minutes ago, with a small allowance for clock differences
🚧

The issuer is not the OpenID configuration issuer

The iss claim in webhooks is https://identity.moneyhub.co.uk. The issuer in the OpenID configuration is https://identity.moneyhub.co.uk/oidc. If you copy the issuer from the OpenID configuration, verification will fail.

Read the request body as raw text and verify that exact string. If a JSON body parser or other middleware changes the body first, the signature check fails.

After verifying, map the claims to the JSON envelope fields as described in JWT claims.

Retrieve the public signing keys

Our public keys are published at the jwks_uri in our OpenID configuration, which is https://identity.moneyhub.co.uk/oidc/certs. The response looks something like:

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "D9JANmYfSG_kbt2KRpTKo7QCNH2_RK-0a787yjm07zw",
      "e": "AQAB",
      "n": "g3oKu2O5TgkMoN_lhU8Y5-2sai9y_XPGaoZ3cf1pxdbTFdbsaBjHWjg9tBPsD8xXEEKGMNGIQBLIUVopnrtVFsWe8AI04k5PfUMWIUhto6LHOj64p2ZMRsL6N1CIYXUehmIG7esTHUcgIGmClsto6cno211ZPQjZkkqeOUzmfe0LE7oiGygXbAWtt0jNHlAw-He5tabcup1ezHZ6eAG7hklhIh4-KXICWvpWKNaqxlfX_weEWmHft18mbz1ZFGQfN0gb0VggtzKqe7QBi8SAAIPbiMre_8vEhQdfw0X_EnJnlFu-FBR984vT3eblYGU0sXkQUoD8pAUhNl3UANu0ww"
    },
    {
      "kty": "EC",
      "use": "enc",
      "kid": "T7rxogsi9VUcI_SmoePSJvzif4qIUarPiFAyqTTUuYc",
      "crv": "P-256",
      "x": "GERoSJmHdL5VTyKTv9-XuRiNL4Y4TZnQWeTPewtW8n4",
      "y": "eU5FlDQipbdr96bCSsnH9tTHckOw7gpOZ6-2uavcwnI"
    }
  ]
}
FieldDescription
ktyKey type. Webhooks are signed with an RSA key
useHow the key is used. sig keys are for signatures. Ignore enc keys for webhooks
kidKey ID. Match it to the kid in the JWT header to find the right key
eThe RSA public exponent
nThe RSA modulus

The JWKS can contain more than one signing key, so always select the key by kid rather than taking the first sig key.

🚧

Don't hard-code the keys

Keys can change over time without notice. Fetch them from the jwks_uri and cache them. If a webhook has a kid you don't have, fetch the JWKS again. The libraries in the examples below do this for you.

Examples

These examples verify a token and return its claims. Building a Webhook Receiver shows them inside a complete receiver.

// npm install jose
import { createRemoteJWKSet, jwtVerify } from "jose"

const MONEYHUB_JWKS = createRemoteJWKSet(new URL("https://identity.moneyhub.co.uk/oidc/certs"))

export async function verifyMoneyhubWebhook(token, clientId) {
  const { payload: claims } = await jwtVerify(token, MONEYHUB_JWKS, {
    issuer: "https://identity.moneyhub.co.uk",
    audience: clientId,
    algorithms: ["RS256"],
    maxTokenAge: "10m",
    clockTolerance: "30s",
  })
  return claims
}
# pip install "pyjwt[crypto]"
import time

import jwt

MONEYHUB_JWKS = jwt.PyJWKClient("https://identity.moneyhub.co.uk/oidc/certs")


def verify_moneyhub_webhook(token, client_id):
    signing_key = MONEYHUB_JWKS.get_signing_key_from_jwt(token)
    claims = jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],
        audience=client_id,
        issuer="https://identity.moneyhub.co.uk",
        options={"require": ["iat", "jti", "sub", "events"]},
        leeway=30,
    )
    if time.time() - claims["iat"] > 600:
        raise jwt.InvalidTokenError("Webhook token is too old")
    return claims

Both functions raise an error if any check fails. Respond with a 4xx status and don't process the webhook.


What’s Next