Webhook Validation

Webhook Validation

There are two steps to validating a webhook: validating the timestamp and the signature. The signature is an HMAC SH256 which requires a shared symmetric key to validate. These keys are provided in JWKS, both as part of the Create Subscription response and available directly from the JWKS endpoint, and are routinely rotated.

Obtaining and Keeping your JWKS up to date

Webhook users are issued a single set of keys no matter how many subscriptions that user creates. You should cache the JWKS and not call this endpoint in response to every webhook received. We periodically rotate keys, so to ensure you always have the latest keys you should call the /jwks endpoint at least once a day. Despite this you may still receive a message with a newer key than you have, indicated by a message being signed with a kid that isn't present in your current JWKS. If this occurs, simply call the /jwks endpoint to obtain the latest JWKS and then proceed with validating the signature. As the JWKS returned contains the latest and a few previous keys there is no need to merge JWKS's, you can simply overwrite your cache with the latest JWKS returned.

Understanding the Webhook Signature

Events are signed with a JWS (JSON Web Signature) per RFC-7515. The keys used in the signed are provided in a JWK Set per RFC-7517. The HS256 algorithm is described in RFC-7518. Most languages have implementations of these standards, for example node-jose and lestrrat-go/jwx. If you prefer to roll your own, enough information is provided here (and in the standards) sto do so. JWS objects are normally provided in the following form:

ASCII(BASE64URL(UTF8(JOSE Header))) || '.' || BASE64URL(JWS Payload) || '.' || BASE64URL(JWS Signature)

As our payload is the raw HTTP request body, we provide a JWS in the "detached content" format:

ASCII(BASE64URL(UTF8(JOSE Header))) || '.' || '.' || BASE64URL(JWS Signature)

The JWS object is placed in the X-JWS-Signature HTTP Header.

JOSE Header

The first section (delineated by a period) of the JWS object is the encoded JOSE header. The decoded content of the JOSE header is:

{ "alg": "HS256", "kid": "48a607ef-396c-4934-ba68-c200960b4d0a", "Timestamp": "2023-02-22T21:57:48+00:00", "crit": ["Timestamp"] }

In addition to the standard values of alg, kid, and crit, we also populate a critical header Timestamp. alg will always contain the value HS256 as we only sign with the SHA256 HMAC algorithm. kid contains the id of the key used to sign this specific message (always a UUID). It is used to look up the correct key in the JWKS. crit contains the list of critical headers not included in the RFC-7515 standard, which in our case is Timestamp. Timestamp contains the ISO-8601 timestamp at which this message was sent.

Detached Content

The second section is left empty ("detached content" form) as it can be calculated as the Base64URL encoding of the raw request bytes.

BASE64URL(HTTP Request Body)

Signature

The third section is the value of the HS256 signature, calculated as:

HMAC_SHA256(ASCII(BASE64URL(UTF8(JOSE Header))) || '.' || BASE64URL(JWS Payload), Key)

Validating the Signature

For validation some JOSE libraries require you to complete the JWS by inserting the Base64URL encoded body between the two periods while others directly supported the detached content format. For example, node-jose shows needing to complete the signature while lestrrat-go/jwx does not (see the Webhook Client Recipe for the full examples):

const base64url = require('base64url'); const jose = require("node-jose"); const opts = { algorithms: ['HS256'], handlers: { "Timestamp": function (jwe) { var timestamp = new Date(jwe.header['Timestamp']) if (Date.now() - timestamp > 60000){ jwe.TimestampVerification = false } else { jwe.TimestampVerification = true } } } }; app.use('/your-callback-endpoint', async (req, res, next) => { let JWSParts = req.get('X-JWS-Signature').split('..') fullJWS = JWSParts[0] + '.' + base64url(req.body.toString()) + '.' + JWSParts[1] try { await jose.JWS.createVerify(keystore).verify(fullJWS, opts); } catch(e) { const isKeyMissing = keystore.get(JSON.parse(base64url.decode(JWSParts[0])).kid) === null if(isKeyMissing) { await updateKeystore(); try { await jose.JWS.createVerify(keystore).verify(fullJWS, opts); } catch(e) { res.status(400).send(e.message); } } else { res.status(400).send(e.message); } } next(); }) app.post('/your-callback-endpoint', async (req, res, next) => { const receivedEvent = HTTP.toEvent({ headers: req.headers, body: req.body }); processWebhook(receivedEvent); })
// GenerateJwsVerificationMiddleware returns a middleware to verify the JWS Signature with a cached keyset func GenerateJwsVerificationMiddleware(keySet *jwk.Cache) func(handler http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { //Get the X-JWS-Signature Header xJwsSig := r.Header.Get("X-JWS-Signature") if xJwsSig == "" { fmt.Printf("Missing header") http.Error(w, "Invalid JWS", http.StatusBadRequest) return } //Read the body body, err := io.ReadAll(r.Body) if err != nil { log.Fatal(err) } //Get the key set ks, err := keySet.Get(r.Context(), jwksUrl) if err != nil { fmt.Printf("failed to get jwks: %s\n", err) http.Error(w, "JWKS Issue", http.StatusInternalServerError) return } //Parse the JWS parsed, err := jws.Parse([]byte(xJwsSig)) if err != nil { fmt.Printf("failed to parse X-JWS-Signature: %s\n", err) http.Error(w, "Invalid X-JWS-Signature", http.StatusBadRequest) return } //Get the protected headers protectedHeaders, err := parsed.Signatures()[0].ProtectedHeaders().AsMap(context.Background()) if err != nil { fmt.Printf("failed to get protected headers as a map: %s\n", err) http.Error(w, "Protected Header Issue", http.StatusInternalServerError) return } //Validate Timestamp timestamp, err := time.Parse(time.RFC3339, protectedHeaders["timestamp"].(string)) if time.Since(timestamp) > 1*time.Minute || err != nil { fmt.Printf("Bad timestamp") http.Error(w, "Invalid JWS", http.StatusBadRequest) return } //If the key is not in the key set, force a refresh if _, ok := ks.LookupKeyID(parsed.Signatures()[0].ProtectedHeaders().KeyID()); !ok { ks, err = keySet.Refresh(r.Context(), jwksUrl) if err != nil { fmt.Printf("failed to refresh the jwks: %s\n", err) } } _, err = jws.Verify([]byte(xJwsSig), jws.WithKeySet(ks), jws.WithDetachedPayload(body)) if err != nil { fmt.Printf("Failed verify: %v\n", err) http.Error(w, "Invalid JWS", http.StatusBadRequest) return } //Continue processing next.ServeHTTP(w, r) }) } }

If you are not using a JOSE library, the JWKS are provided in the following format:

{ "keys": [ { "kty": "oct", "use": "sig", "alg": "HS256", "kid": "48a607ef-396c-4934-ba68-c200960b4d0a", "k": "q43Yihl0vyLZb6t6Ntj0kQ9PaLKQ1wAVDaddAUlYpSY" }, { "kty": "oct", "use": "sig", "alg": "HS256", "kid": "0360c0a3-c56f-4d79-98bb-d8ed68ec1152", "k": "W0aBE14BAMfZp5mh24tJVbmVq2xkfR2ZSkxYsxk1EXo" } ] }

where kty="oct", use="sig", and alg="HS256" are always constant, kid is the key's identifier (always a UUID), and k is the base64url encoding of the key. Using the kid value from the decoded JOSE header you can find the key used to sign the webhook by matching the kid in the JWKS.

The signature is calculated and verified as follows:

//X-JWS-Signature = joseHeaders + ".." + JWSSignature

//Remove the base64url encoding on the signature
jwsSig = base64urlDecode(JWSSignature)

//Caluclate the hash ourselves
//    joseHeaders are the base64url encoded JOSE headers, i.e. the content of the JWS 
//    from the beginning up to but not including the first period
calculatedSig = HMACSHA256(joseHeaders + "." + base64url(HTTP Request Body), k)

//N.B. a constant-time comparison must be used to prevent timing attacks
if(!timingSafeEqual(jwsSig, calculatedSig)) {
   //Message signature is invalid
}

Validating the Timestamp

Only the cryptographically signed Timestamp from the protected JOSE header should be validated, not the plain HTTP header from the Cloud Event standard, as the JOSE headers are secure but the HTTP headers are not. This ensures the value has not been modified by a malicious actor. The value should be within a reasonable threshold to the current time while validating, e.g. one minute:

var timestamp = new Date(jwe.header['Timestamp']) if (Date.now() - timestamp > 60000){ //Invalid Timestamp } else { //Valid }
//Validate timestamp timestamp, err := time.Parse(time.RFC3339, protectedHeaders["Timestamp"].(string)) if time.Since(timestamp) > 1*time.Minute || err != nil { fmt.Printf("Bad Timestamp") http.Error(w, "Invalid JWS", http.StatusBadRequest) return }