Verify webhooks
API reference for verifying webhooks
Plaid signs all outgoing webhooks so that you can verify the authenticity of any incoming webhooks to your application. A message signature is included in the Plaid-Verification
header (Note: this is the canonical representation of the header field, but HTTP 1.x headers should be handled as case-insensitive, HTTP 2 headers are always lowercase). The verification process is optional and is not required for your application to handle Plaid webhooks.
The verification process requires understanding JSON Web Tokens (JWTs) and JSON Web Keys (JWKs). More information about these specifications can be found at jwt.io.
Libraries for interpreting and verifying JWKs and JWTs most likely exist in your preferred language. It is highly recommended that you utilize well-tested libraries rather than trying to implement these specifications from scratch.
Steps to verify webhooks
Extract the JWT header
Extract the Plaid-Verification HTTP header from any Plaid webhook (to get a webhook, see firing webhooks in Sandbox). The value of the Plaid-Verification header is a JWT, and will be referred to as "the JWT" in following steps.
Using your preferred JWT library, decode the JWT and extract the header without validating the signature. This functionality most likely exists in your preferred JWT library. An example JWT header is shown below.
1{2 "alg": "ES256",3 "kid": "bfbd5111-8e33-4643-8ced-b2e642a72f3c",4 "typ": "JWT"5}
Ensure that the value of the alg
(algorithm) field in the header is "ES256"
. Reject the webhook if this is not the case.
Extract the value corresponding to the kid
(key ID) field. This will be used to retrieve the JWK public key corresponding to the private key that was used to sign this request.
Get the verification key
Use the /webhook_verification_key/get
endpoint to get the webhook verification key.
/webhook_verification_key/get
Get webhook verification key
Plaid signs all outgoing webhooks and provides JSON Web Tokens (JWTs) so that you can verify the authenticity of any incoming webhooks to your application. A message signature is included in the Plaid-Verification
header.
The /webhook_verification_key/get
endpoint provides a JSON Web Key (JWK) that can be used to verify a JWT.
client_id
client_id
. The client_id
is required and may be provided either in the PLAID-CLIENT-ID
header or as part of a request body.secret
secret
. The secret
is required and may be provided either in the PLAID-SECRET
header or as part of a request body.key_id
kid
) from the JWT header.1const request: WebhookVerificationKeyGetRequest = {2 key_id: keyID,3};4try {5 const response = await plaidClient.webhookVerificationKeyGet(request);6 const key = response.data.key;7} catch (error) {8 // handle error9}
Response fields and example
key
alg
crv
kid
kty
use
x
y
created_at
expired_at
request_id
1{2 "key": {3 "alg": "ES256",4 "created_at": 1560466150,5 "crv": "P-256",6 "expired_at": null,7 "kid": "bfbd5111-8e33-4643-8ced-b2e642a72f3c",8 "kty": "EC",9 "use": "sig",10 "x": "hKXLGIjWvCBv-cP5euCTxl8g9GLG9zHo_3pO5NN1DwQ",11 "y": "shhexqPB7YffGn6fR6h2UhTSuCtPmfzQJ6ENVIoO4Ys"12 },13 "request_id": "RZ6Omi1bzzwDaLo"14}
Was this helpful?
Validate the webhook
Interpret the returned key
as a JWK public key. Using your preferred JWT library, verify the JWT using the JWK. If the signature is not valid, reject the webhook. Otherwise, extract the payload of the JWT. It will look something like the JSON below.
1{2 "iat": 1560211755,3 "request_body_sha256": "bbe8e9..."4}
Use the issued at time denoted by the iat
field to verify that the webhook is not more than 5 minutes old. Rejecting outdated webhooks can help prevent replay attacks.
Extract the value of the request_body_sha256
field. This will be used to check the integrity and authenticity of the webhook body.
Compute the SHA-256 of the webhook body and ensure that it matches what is specified in the request_body_sha256
field of the validated JWT. If not, reject the webhook. It is best practice to use a constant time string/hash comparison method in your preferred language to prevent timing attacks.
Note that the request_body_sha256
sent in the JWT payload is sensitive to the whitespace in the webhook body and uses a tab-spacing of 2. If the webhook body is stored with a tab-spacing of 4, the hash will not match.
Caching and key rotation
It is recommended that your application caches the public key for a given key ID, but for no more than 24 hours. This reduces the likelihood of using an expired key to validate incoming webhooks.
It may be possible that a key rotation will take place. If your application encounters a webhook with a key ID that it does not have in its cache, it should make an API request to fetch the associated public key. In addition, your application must refetch any keys from the API that are currently in the cache but not yet expired. This helps to ensure that expired keys are not being used for live validation of webhooks by refreshing the expired_at
field.
Example implementation
The following code shows one example method that can be used to verify webhooks sent by Plaid and cache public keys.
1const compare = require('secure-compare');2const { jwtDecode } = require('jwt-decode'); // syntax for jwtDecode 4.0 or later3const JWT = require('jose');4const sha256 = require('js-sha256');5const plaid = require('plaid');6
7// Cache for webhook validation keys.8const KEY_CACHE = new Map();9
10const plaidClient = new plaid.Client({11 clientID: process.env.CLIENT_ID,12 secret: process.env.SECRET,13 env: plaid.environments.sandbox,14});15
16const verify = async (body, headers) => {17 const signedJwt = JSON.parse(headers)['plaid-verification'];18 const decodedToken = jwtDecode(signedJwt);19 // Extract the JWT header20 const decodedTokenHeader = jwtDecode(signedJwt, { header: true });21 // Extract the kid value from the header22 const currentKeyID = decodedTokenHeader.kid;23
24 //If key not in cache, update the key cache25 if (!KEY_CACHE.has(currentKeyID)) {26 const keyIDsToUpdate = [];27 KEY_CACHE.forEach((keyID, key) => {28 // We will also want to refresh any not-yet-expired keys29 if (key.expired_at == null) {30 keyIDsToUpdate.push(keyID);31 }32 });33
34 keyIDsToUpdate.push(currentKeyID);35
36 for (const keyID of keyIDsToUpdate) {37 const response = await plaidClient38 .webhookVerificationKeyGet({39 key_id: keyID,40 })41 .catch((err) => {42 // decide how you want to handle unexpected API errors,43 // e.g. retry later44 return false;45 });46 const key = response.data.key;47 KEY_CACHE.set(keyID, key);48 }49 }50
51 // If the key ID is not in the cache, the key ID may be invalid.52 if (!KEY_CACHE.has(currentKeyID)) {53 return false;54 }55
56 // Fetch the current key from the cache.57 const key = KEY_CACHE.get(currentKeyID);58
59 // Reject expired keys.60 if (key.expired_at != null) {61 return false;62 }63
64 // Validate the signature and iat65 try {66 const keyLike = await JWT.importJWK(key);67 // This will throw an error if verification fails68 const { payload } = await JWT.jwtVerify(signedJwt, keyLike, {69 maxTokenAge: '5 min',70 });71 } catch (error) {72 return false;73 }74
75 // Compare hashes.76 const bodyHash = sha256(body);77 const claimedBodyHash = decodedToken.request_body_sha256;78 return compare(bodyHash, claimedBodyHash);79};