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
}
Updated almost 2 years ago