Verifying and responding to a signed webhook notification
When you have created a subscription to a webhook event, and that event occurs, you will receive a webhook notification. A signed notification will contain a signature in the Vonage-Signature
header that you can use to verify that Vonage sent the notification and has not been tampered with. This guide describes how you should verify a signed webhook notification. You can verify the notification in two parts:
- Verify the signature
- Verify the payload
In this page |
---|
The webhook notification you receive includes the following:
- A payload. The payload is the request body in the notification and uses the CloudEvent specification.
- A signature (in a JSON Web Token (JWT)). A signed webhook notification will contain a signature. The signature is generated from the following values:
- The payload hash. The payload is hashed using the HMAC with SHA-256 (HS-256) algorithm.
- A base64-encoded subscription secret. The subscription secret was provided or generated when the webhook subscription was created.
The following simplified values are used throughout the guide. In reality, the values will be longer and more complex.
- Subscription secret: my_secret_key
- Base64 encoded secret: bXlfc2VjcmV0X2tleQ==
- Payload: request_body
- Hashed payload: #_request_body_#
- Signature: a1b2c3d4
Verifying the signature in the webhook notification
For a complete example of how to consume and validate VCC webhooks, see NodeJs with ExpressJs consumer example.
When you receive a signed webhook notification, the notification contains the signature in a JWT stored in the Vonage-Signature
header. You should verify the signature to ensure the notification originated from Vonage.
Expiring signatures
You can use various libraries to verify JWTs. The following example uses jsonwebtoken to decode and verify the JWT (signature):
const { verify } = require('jsonwebtoken') const token = ... // Extracted token from Vonage-Signature header const secretBytes = Buffer.from(process.env.SECRET, 'base64') try { const decodedToken = verify(token, secretBytes) } catch (error) { // Validation failed }
If verification succeeds, the webhook notification was signed by Vonage.
Example values
Parameter | Value |
---|---|
token | a1b2c3d4 |
process.env.SECRET | my_secret_key |
secretBytes | bXlfc2VjcmV0X2tleQ== |
Verifying the payload
When you have verified the signature in the JWT, you can then verify the payload to ensure no tampering has occurred. To do so, you must compare the SHA-256 hash with the hashed payload (in the payload_hash
field) in the JWT. If they do not match, the payload has been tampered with and the notification should be rejected.
You must verify the payload using the raw request body. Frameworks will often parse the request body into a JSON object, making it easier to work with. Converting this JSON object back into a string might result in a different value from the original, which would cause verification to fail.
The payload_hash
field uses a hex digest, meaning the hash is represented using hexadecimal encoding. Depending on how you compare the values, you must either:
- Use a hex digest when creating a hash of the payload (and then compare the strings)
- Decode the
payload_hash
using hex encoding (and then compare the bytes)
Cryptographic comparison functions typically compare bytes and provide additional security benefits over a standard equivalence check. The following example uses node's crypto library to compare the values:
const { createHash, timingSafeEqual } = require('crypto'); const tokenPayloadHash = ... // extracted from decoded JWT const payload = ... // raw body as bytes const payloadHashBytes = createHash('sha256') .update(payload) .digest() const expectedHashBytes = Buffer.from(tokenPayloadHash, 'hex') // timingSafeEqual throws if different lengths if (timingSafeEqual(payloadHashBytes, expectedHashBytes)) { console.log('Payload verified!') } else { console.error('Payload mismatch!') }
Explanation of variables
Variable | Contents |
---|---|
tokenPayloadHash | #_request_body_# |
payload | request_body |
payloadHashBytes | #_request_body_# (in bytes) |
expectedHashBytes | #_request_body_# (in bytes) |
Responding to the webhook notification
When you have validated/verified the notification, your server must respond with a success status code — a status code between 200 OK and 205 Reset Content.
Troubleshooting
Request body not parsing as JSON
VCC webhook notifications include a CloudEvent payload and so Content-Type
is set to application/cloudevents+json
. Web frameworks often parse the request body according to the request Content-Type
and some don't recognize application/cloudevents+json
. This results in the framework not parsing the payload as JSON.
One example of a framework that doesn't recognize the content type is the express function, express.json(). In this case, you must include the content type as a parameter to the function:
express.json({ type: ['application/cloudevents+json'] })
JWT validation fails when using the correct secret
Secret not base64 decoded
Some libraries provide methods to validate a JWT using a string representation of the secret. Such methods will typically perform the validation using a UTF8 decoding of the secret. VCC webhooks subscription secrets are base64 encodings of a binary secret. Treating the subscription secret as plaintext – that is, UTF8 encoding – when validating the JWT will fail. This is because it results in a different binary value from that which was used to sign the JWT.
There are various libraries available to handle this decoding for you. For example:
- https://nodejs.org/api/buffer.html#static-method-bufferfromstring-encoding
- https://learn.microsoft.com/en-us/dotnet/api/system.convert.frombase64string?view=net-8.0
Secret not big enough
The algorithm used to sign VCC webhook notifications is HS256 (HMAC with SHA256). As described in https://tools.ietf.org/html/rfc7518#section-3.2, the length for this algorithm should be at least 256 bits (32 bytes). Some libraries refuse to validate a JWT with a secret smaller than this and may fail in ways that are not always obvious.
Payload hash comparison fails with a valid JWT
The web framework you are using might be parsing the request body into a JSON object. While this is useful for your application code to handle the event, converting this JSON object back into a string or byte array might result in a different value from the original. For example:
λ node > const original = '{ "a" : 123 }' > const parsed = JSON.parse(original) { a: 123 } > const stringified = JSON.stringify(parsed) '{"a":123}' > stringified === original false
You should therefore validate the request using the raw request body before it is manipulated by your framework.
You will likely need access to both the raw and parsed request body to validate and process events. How this can be achieved will vary depending on the framework, but here is an example of how to achieve it in ExpressJs:
// req.Body will contain parsed JSON object, referenced by application code // req.rawBody will contain the original bytes, referenced by signature validation express.json({ verify: (req, res, buf) => { req.rawBody = buf } })