Webhook verification

A guide to avoid processing unwanted webhooks

Since your webhook endpoint needs to be public, Vumi signs every webhook message we produce with a JSON Web Token (JWT) in a custom header vumi-verification. This JWT will make it possible for you to verify the message validity and source. This verification is not needed in order to process our webhook messages, but we highly encourage you to incorporate it into your flow to avoid possible malicious messages.

The verification process involves parsing and verifying a JWT token, so it is highly advisable for you to be familiar with this technology.

Verification steps

Extract the Key ID

The first step for verifying the validity of our message is to obtain the Key ID from the JWT. Start this step by getting the vumi-verification header from the webhook. The value of this header will be a valid JWT.

Once you have the webhook JWT, proceed by decoding its header without verifying its signature. Although most common libraries will support this operation. sometimes this step might be troublesome because your library doesn't accept JWT decoding without verifying its signature. If this is your case, try using a different library or just decode it yourself (this not recommended, but it should be easy since it's just Base64 encoded). Once you have your JWT header decoded, you should have an object similar to the following:

{
  "alg": "ES256",
  "kid": "195a5da1-7643-44ba-bf7b-dca96c0c014a",
  "typ": "JWT"
}

Ensure that the properties alg and typ match "ES256" and "JWT" respectively, as shown above. If they don't match these values, automatically reject the webhook message.

Get the verification key

When the JWT Header is valid, extract the kid property (Key ID). This property identifies the key that was used to sign the webhook message. It should be a UUID.

Perform a request to the endpoint get verification key passing the kid you just extracted. This endpoint will return a JSON Web Key (JWK) that can be used to verify our JWT.

Validate the JWT

The last step would be to validate the JWT with the JWK you just obtained from our API. Most JWT have support for directly validating a JWT using a JWK. You should use the received as a JWK public key in order to validate the JWT.

Once you have validated the token signature, the next verification step would be to check that the body is the expected one and has not been modified. In order to do that, we include a hash in the JWT body so that you can compare it to the hash of the actual body. This is the JWT body that you'll receive:

{
  "iat": 1718796049,
  "request_body_sha256": "5a820ce85e867e44dc41873718b27a35739e13e943f091341b4b09a082ad942e"
}

First of all, you should look at the iat field that indicates when the message was issued. You should not process old messages to avoid problems like message re-processing. Our recommendation is for you to discard messages older than 3 minutes, but you can adapt this time to your needs.

The next step would be to check hash-matching. You should compute the SHA-256 of the webhook payload. If you directly hash the RAW JSON body that we send (recommended), it should match exactly the hash you can find in the JWT Body, since they are formatted the same way. Use a constant time comparison method to prevent timing attacks.

To give you an example of how to correctly hash, the previous sha256 would match the following body if hashed raw:

{"credentials_token":"test","status":"ERRORED","error_info":"INCORRECT_CREDENTIALS","reason":null,"entity":null,"iat":1718796049}

If you decide to process the JSON Body instead of hashing it raw, take into account that tab-indentation and line breaks matter. To make things simpler, we directly hash the minified version of the JSON without any whitespaces, so that there are less problems when comparing hashes.

Example implementation

In this section we'll include an example implementation of webhook validation following the guidelines we mentioned above. This is a very simple example using Java 11, spring boot and the library io.jsonwebtoken:0.12.0. We'll avoid unnecessary code like request implementation and stick to the validation of the JSON. This library does not include a way of decoding a JWT without verifying it first, so this example also includes how to do that if your library does not support it.

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.jwk.Jwk;
import io.jsonwebtoken.jwk.Jwks;
import java.security.Key;
import java.security.PublicKey;
import java.util.Base64;

@RestController
public class FinlinkWebhookController {

    private final ObjectMapper objectMapper;
    private final FinlinkClient finlinkClient;

    public FinlinkWebhookController(ObjectMapper objectMapper, FinlinkClient finlinkClient) {
        this.objectMapper = objectMapper;
        this.finlinkClient = finlinkClient;
    }

    /**
     * Handles the Finlink webhook by verifying the JWT token provided in the header.
     *
     * @param vumiVerification JWT token from the webhook header
     * @return ResponseEntity with a string indicating the status of the webhook handling
     */
    public ResponseEntity<String> handleFinlinkWebhook(@RequestHeader("vumi-verification") String vumiVerification,
                                                       @RequestBody FinlinkWebhookMessage message) {
        try {
            // Split the JWT token into its parts
            String[] parts = vumiVerification.split("\\.");
            String headerJson = new String(Base64.getUrlDecoder().decode(parts[0]));

            // Parse the JWT header
            FinlinkWebhookJwtHeader verificationHeader = objectMapper.readValue(headerJson, FinlinkWebhookJwtHeader.class);

            // Fetch the JWK (JSON Web Key) for verification using the key ID (kid)
            String jwkJson = finlinkClient.getVerificationKey(verificationHeader.getKid());
            Jwk<? extends Key> jwk = Jwks.parser().build().parse(jwkJson);

            // Parse and verify the JWT token using the public key
            Jwts.parser()
                    .verifyWith((PublicKey) jwk.toKey())
                    .build()
                    .parseSignedClaims(vumiVerification)
                    .getPayload();

            // Handle valid webhook
            // We recommend you sending the webhook message to a queue and taking care of it somewhere else
            return ResponseEntity.ok("Webhook verified successfully");
        } catch (Exception e) {
            // Handle invalid webhook
            return ResponseEntity.status(400).body("Invalid webhook");
        }
    }
}

Recommendations

Caching keys

Since our system uses a limited number of keys to sign JWT tokens in webhooks, ideally you should cache the keys that have already been queried to avoid unnecessary calls.

We have automatic rotation in place, so new keys might be added. Everytime you encounter a new key, just fetch it and store it in the cache. Our recommendation is that you clear the cache every day to avoid storing expired keys, either by removing every key completely or re-fetching stored keys to see if they have been expired due to rotation.

Best practices

If you haven't read it yet, we highly encourage to read about best practices when using our webhook solution.

Last updated