Verify webhooks

API reference for verifying webhooks

Webhook verification overview

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.

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.

Extract the JWT header value without validating the signature. This functionality most likely exists in your preferred JWT library. An example JWT header is shown below.

1
2
3
4
5
{
"alg": "ES256",
"kid": "bfbd5111-8e33-4643-8ced-b2e642a72f3c",
"typ": "JWT"
}

Ensure that 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 public key corresponding to the private key that was used to sign this request.

/webhook_verification_key/get

Use the /webhook_verification_key/get endpoint to get the webhook verification key.

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.

webhook_verification_key/get

Request fields and example

client_idstring
Your Plaid API 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.
secretstring
Your Plaid API secret. The secret is required and may be provided either in the PLAID-SECRET header or as part of a request body.
key_idrequiredstring
The key ID ( kid ) from the JWT header.
1
2
3
4
const response = await client.getWebhookVerificationKey(keyId).catch((err) => {
// handle error
});
const key = response.key;
webhook_verification_key/get

Response fields and example

keyobject
A JSON Web Key (JWK) that can be used in conjunction with JWT libraries to verify Plaid webhooks
algstring
The alg member identifies the cryptographic algorithm family used with the key.
crvstring
The crv member identifies the cryptographic curve used with the key.
kidstring
The kid (Key ID) member can be used to match a specific key. This can be used, for instance, to choose among a set of keys within the JWK during key rollover.
ktystring
The kty (key type) parameter identifies the cryptographic algorithm family used with the key, such as RSA or EC.
usestring
The use (public key use) parameter identifies the intended use of the public key.
xstring
The x member contains the x coordinate for the elliptic curve point.
ystring
The y member contains the y coordinate for the elliptic curve point.
created_atinteger
expired_atnullableinteger
request_idstring
A unique identifier for the request, which can be used for troubleshooting. This identifier, like all Plaid identifiers, is case sensitive.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"key": {
"alg": "ES256",
"created_at": 1560466150,
"crv": "P-256",
"expired_at": null,
"kid": "bfbd5111-8e33-4643-8ced-b2e642a72f3c",
"kty": "EC",
"use": "sig",
"x": "hKXLGIjWvCBv-cP5euCTxl8g9GLG9zHo_3pO5NN1DwQ",
"y": "shhexqPB7YffGn6fR6h2UhTSuCtPmfzQJ6ENVIoO4Ys"
},
"request_id": "RZ6Omi1bzzwDaLo"
}
Validate the webhook

Interpret key as a JWK, and use your preferred library to validate that the signature of the JWK is valid. 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
3
4
{
"iat": 1560211755,
"request_body_sha256": "bbe8e9..."
}

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
//This example uses jose v2, not the latest version
const compare = require('secure-compare');
const jwt_decode = require('jwt-decode');
const JWT = require('jose');
const sha256 = require('js-sha256');
const plaid = require('plaid');
// Cache for webhook validation keys.
const KEY_CACHE = new Map();
const plaidClient = new plaid.Client({
clientID: process.env.CLIENT_ID,
secret: process.env.SECRET,
env: plaid.environments.sandbox,
});
const verify = async (body, headers) => {
const signedJwt = JSON.parse(headers)['plaid-verification'];
const decodedToken = jwt_decode(signedJwt);
const decodedTokenHeader = jwt_decode(signedJwt, { header: true });
const currentKeyID = decodedTokenHeader.kid;
//If key not in cache, update the key cache
if (!KEY_CACHE.has(currentKeyID)) {
const keyIDsToUpdate = [];
KEY_CACHE.forEach((keyID, key) => {
if (key.expired_at == null) {
keyIDsToUpdate.push(keyID);
}
});
keyIDsToUpdate.push(currentKeyID);
for (keyID of keyIDsToUpdate) {
const verificationKeyResponse = await plaidClient
.getWebhookVerificationKey(keyID)
.catch((err) => {
// decide how you want to handle unexpected API errors,
// e.g. retry later
return false;
});
const key = verificationKeyResponse.key;
KEY_CACHE.set(keyID, key);
}
}
// If the key ID is not in the cache, the key ID may be invalid.
if (!KEY_CACHE.has(currentKeyID)) {
return false;
}
// Fetch the current key from the cache.
const key = KEY_CACHE.get(currentKeyID);
// Reject expired keys.
if (key.expired_at != null) {
return false;
}
// Validate the signature and iat
try {
JWT.verify(signedJwt, key, { maxTokenAge: '5 min' });
} catch (error) {
return false;
}
// Compare hashes
const bodyHash = sha256(body);
const claimedBodyHash = decodedToken.request_body_sha256;
return compare(bodyHash, claimedBodyHash);
};