Webhook Security
Why Verify?
Subscribing to Benchling Webhooks requires hosting a publicly available endpoint to receive them. This introduces the potential for bad actors to attempt to impersonate Benchling or otherwise maliciously interact with the endpoint. To prevent this, Benchling includes a unique signature and timestamp with every webhook. By verifying this signature and timestamp, you can confirm the webhook was sent by Benchling.
For more information, check out the following resource: https://webhooks.fyi/security
Endpoint Signing Using JSON Web Key Sets
When your app is created in a tenant using the app manifest, Benchling creates an endpoint signing keypair (i.e. JWKS). Benchling generates webhook signatures using Elliptic Curve Signatures, which can be verified by your app using the JWKS published for your app. JWKS keysets can be obtained from a global endpoint using your app id:
https://apps.benchling.com/api/v1/apps/{app_definition_id}/jwks
Your app should query the JWKS endpoint for the current values at intervals of no more than 6 hours, since Benchling frequently rotates these keypairs (and the corresponding public keys). Below is an example of a JWKS:
Do NOT hardcode the initial JWKS values!
Verifying Webhooks Manually
Benchling recommends using a package or library suitable for your language to verify the signature. Some common libraries are listed below in the Sample Verification Libraries section. The general steps required to verify a signature are outlined below; for a more detailed walkthrough with example Python code, check out the tutorial at the end of this guide.
Verify timestamp
The webhooks-timestamp
header includes a timestamp that corresponds to when Benchling attempted to send the webhook, used to prevent timestamp attacks. You should compare this timestamp against your system timestamp in UTC to make sure it's within your tolerance; Benchling recommends using a 5 minute tolerance for this comparison.
Creating signed_content
String
signed_content
StringEach webhook includes three headers with additional information that are used for verification:
Webhook-Id
: the unique message identifier for the webhook message. This identifier is unique across all messages, but will be the same when the same webhook is being resent (e.g. due to a previous failure).Webhook-Timestamp
: timestamp in seconds since epoch.Webhook-Signature
: the Base64 encoded list of signatures (space delimited).
The first verification step involves concatenating the webhook id, timestamp, and payload values, separated by the full-stop character (i.e. .
). In code, it will look something like this (where body
is the raw body of the request):
signed_content = "${webhook_id}.${webhook-timestamp}.${body}"
Note: The signature is sensitive to any changes, so even a small change in the body will cause the signature to be completely different. This means that you should not alter the body in any way before verifying.
Getting webhook-signature
webhook-signature
The webhook-signature
header is composed of a list of signatures and their corresponding version identifiers, separated by a single space. Most often there is only a single signature, though due to keypair rotation there could be multiple signatures. Itβs important to iterate through and attempt to validate each signature, since if any one of them are valid, the webhook is legitimate.
An example webhook-signature
header may look like this:
`v1b``,``dYhtchoyr6js2AODL5VkpoPX5BN8SEiMPsBbKYEUK1RU``/+``lYEkYrcu3SYGzXAGnvsHXQvuwdpZFg``/``2ty8VO``/``uQ``==`` `
`v1bder``,``MEQCIHWIbXIaMq``+``o7NgDgy``+``VZKaD1``+ABcDhF``jD7AWymBFCtUAiBU``/+``lYEkYrcu3SYGzXAGnvsHXQvuwdpZFg``/``2ty8VO``/``uQ``==
v2bder,MEQCIHWIbXIaMq+o7MgDgy+VZKaD1+QTfEhIjD7AWymBFCtUAiBU/+lYEkYrcu3SYGzXAGnvsHXQvuwdpZFg/2ty8VO/uQ==
`
These example signatures represent the following:
v1b
- a raw ECDSA signature value, in big-endian orderv1bder
- a DER encoded ECDSA signature valuev2bder
- another DER encoded ECDSA signature value
Note: Make sure to remove the version prefix and delimiter (e.g.
v1b,
orv1bder,
) before verifying the signature.
The webhook signature header includes both raw ECDSA values (e.g. v1b
) and DER encoded values (e.g. v1bder
) for convenience; your choice of verification library will likely determine which signature encoding to use (see Sample Verification Libraries below). For the purposes of our example implementation, weβre using the DER encoded signature values.
Create public key and validate signature
Youβll need to convert the JWKS set queried from Benchling into a list of EllipticCurvePublicKeys which you can use to verify the signed_content
. While the specifics will depend on your implementation, in code, this might look something like this:
pubkey = load_pem_public_key(jk.export_to_pem())
With your public key in hand, you can iterate over the webhook signatures. Here, weβre working with the DER
encoded signatures:
for der_signature in der_signatures:
raw_signature = base64.b64decode(der_signature)
try:
pubkey.verify(raw_signature, bytes(to_verify, "utf-8"), ec.ECDSA(hashes.SHA256()))
except InvalidSignature:
continue
For a more in-depth example of this process, check out the tutorial below.
Sample Verification Libraries
Below are some common cryptographic libraries useful for verifying ECDSA signatures:
- Python: https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ec/#elliptic-curve-signature-algorithms
- Node: https://nodejs.org/api/crypto.html
- Rust: https://docs.rs/signatory/latest/signatory/ecdsa/struct.Signature.html
- Ruby: https://ruby-doc.org/stdlib-3.1.2/libdoc/openssl/rdoc/OpenSSL/PKey/EC.html
- Java: https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/security/class-use/Signature.html (also consider BouncyCastle
Tutorial: Webhook Signature Validation in Python
import base64
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from jwcrypto import jwk
import requests
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.exceptions import InvalidSignature
MY_APP_ID = "app_foobarbaz"
class WebhookVerificationError(Exception):
pass
def get_der_signatures_from_versioned_signatures(versioned_signatures):
"""
Signature format is f"v{version_number}der,{signature}"
"""
der_signatures = []
for versioned_sig in versioned_signatures:
_version, sig = versioned_sig.split(',')
if 'der' in _version :
der_signatures.append(sig)
if der_signatures is []:
assert False, 'Expected to find a der encoded signature'
return der_signatures
# This should hit the public URL
def get_jwks_with_caching():
return jwk.JWKSet.from_json(requests.get(f"https://benchling.com/apps/jwks/{MY_APP_ID}").text)
def __verify_timestamp(timestamp_header: str) -> datetime:
webhook_tolerance = timedelta(minutes=5)
now = datetime.now(tz=timezone.utc)
try:
timestamp = datetime.fromtimestamp(float(timestamp_header), tz=timezone.utc)
except Exception:
raise WebhookVerificationError("Invalid Signature Headers")
if timestamp < (now - webhook_tolerance):
raise WebhookVerificationError("Message timestamp too old")
if timestamp > (now + webhook_tolerance):
raise WebhookVerificationError("Message timestamp too new")
return timestamp
def verify(data:dict, headers:dict)-> bool:
msg_timestamp = headers["webhook-timestamp"]
__verify_timestamp(msg_timestamp)
to_verify = f'{headers["webhook-id"]}.{msg_timestamp}.{data}'
signatures = headers["webhook-signature"].split(' ')
der_signatures = get_der_signatures_from_versioned_signatures(signatures)
jwks = get_jwks_with_caching()
any_valid = False
for jk in jwks:
pubkey = load_pem_public_key(jk.export_to_pem())
for der_signature in der_signatures:
raw_signature = base64.b64decode(der_signature)
try:
pubkey.verify(raw_signature, bytes(to_verify, "utf-8"), ec.ECDSA(hashes.SHA256()))
except InvalidSignature:
continue
any_valid = True
if not any_valid:
raise WebhookVerificationError("No matching signature found")
return any_valid
Updated 4 months ago