Webhook Security for Advocate Programs

Webhook requests originating from your Advocate program are signed so that you can confirm that the request is legitimate. Signatures are specific to every webhook, allowing you to confirm that the message was not intercepted in a monster-in-the-middle attack.

All Advocate webhooks include two signatures that can be used to verify authenticity.

SignatureDescription
X-Hook-JWS-RFC-7797 (Recommended)A JSON Web Signature (JWS) that supports key rotation, signed using the Advocate JSON Web Key Set (JWKS). It is asymmetrically encrypted.
X-Hook-SignatureA HMAC-SHA1 hash of the hook's body contents, signed by your API key.

Although you can verify the webhook's authenticity via the signature, you may still need to verify the state of the data by making an API call.

🚧

Hook delivery order is not guaranteed

Webhooks may be delivered in a different order than the update events that generated them, so relying on their contents may lead you to build a different final state.


Verify a webhook payload

  1. Confirm that the X-Hook-JWS-RFC-7797 header exists. If it doesn't exist, then the request didn't come from impact.com.
  2. Look up the public keys of the Advocate JWKS. There should be a kid that matches the header of the JWS.

    Note: The JWKS changes regularly and should not be cached in its entirety. However, the kid for an individual JWK is immutable, and therefore it is safe and recommended to cache individual JWK's by their kid indefinitely.
  3. Get the JSON body from the request.
  4. Use a JWT library for your programming language to verify that the body matches the signature. The JWS signature uses a detached payload, so it is of the form JWSHEADER..JWSSIGNATURE.
  5. To implement the verification, some languages may require you to Base64 encode the JWS payload (e.g. the webhook body) in order to verify the JWS. Note that vanilla Base64 does not work in this context. The JWT standard requires each part of a JWT to be encoded using the URL variant of Base64 encoding without padding.

    These libraries support RFC-7797 and JWKS, and simplify verifying a JWS:

    Validation code examples

    Below are code examples of validating the JWS of a webhook request.

    import java.net.MalformedURLException;
    import java.net.URL;
    import java.text.ParseException;
    import java.util.Base64;
    import java.util.Map;
    import com.nimbusds.jose.JOSEException;
    import com.nimbusds.jose.JWSAlgorithm;
    import com.nimbusds.jose.jwk.source.RemoteJWKSet;
    import com.nimbusds.jose.proc.BadJOSEException;
    import com.nimbusds.jose.proc.JWSVerificationKeySelector;
    import com.nimbusds.jose.proc.SecurityContext;
    import com.nimbusds.jose.util.DefaultResourceRetriever;
    import com.nimbusds.jwt.proc.DefaultJWTProcessor;
    import com.nimbusds.jwt.proc.JWTProcessor;
    
    public class JwksExample {
    
      private final JWTProcessor<SecurityContext> advocateJwksJwtProcessor;
      {
        final DefaultJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
        try {
          jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256,
              new RemoteJWKSet<>(new URL("https://app.referralsaasquatch.com/.well-known/jwks.json"),
                  new DefaultResourceRetriever(500, 1500))));
        } catch (MalformedURLException e) {
          throw new RuntimeException(e); // Won't happen. We know the URL is not malformed.
        }
        advocateJwksJwtProcessor = jwtProcessor;
      }
    
      /**
       * Validate the given JWT with the public JWKS and get the claims.
       *
       * @param token The input JWT
       * @return The validated claims as a Map
       * @throws ParseException when the input token is invalid
       * @throws BadJOSEException when the input token's claims contain bad values
       * @throws JOSEException when the input token's signature is incorrect
       */
      public Map<String, Object> validateWithAdvocateJwks(String token)
          throws ParseException, BadJOSEException, JOSEException {
        return advocateJwksJwtProcessor.process(token, null).toJSONObject();
      }
    
      /**
       * Validate a webhook coming from impact.com.
       *
       * @param webhookBody The raw bytes of the webhook body.
       * @param jwsNoPayloadHeader The value of the X-Hook-JWS-RFC-7797 header.
       * @return The validated claims as a Map
       * @throws ParseException when the input token is invalid
       * @throws BadJOSEException when the input token's claims contain bad values
       * @throws JOSEException when the input token's signature is incorrect
       */
      public Map<String, Object> validateAdvocateWebhook(byte[] webhookBody,
          String jwsNoPayloadHeader) throws ParseException, BadJOSEException, JOSEException {
        final String webhookBodyBase64 =
            Base64.getUrlEncoder().withoutPadding().encodeToString(webhookBody);
        final String token = jwsNoPayloadHeader.replace("..", '.' + webhookBodyBase64 + '.');
        return validateWithAdvocateJwks(token);
      }
    
    }
    
    
    import * as jwt from "jsonwebtoken";
    import * as jwksRsa from "jwks-rsa";
    import { Base64 } from "js-base64";
    
    const advocateJwksClient = jwksRsa({
      jwksUri: "https://app.referralsaasquatch.com/.well-known/jwks.json",
      cache: true,
    });
    
    /**
     * Validate the given JWT with the public JWKS and get the claims.
     * @param token The input JWT
     */
    export function validateWithAdvocateJwks(token: string): Promise<object> {
      return new Promise((resolve, reject) => {
        jwt.verify(
          token,
          (header, callback) => {
            advocateJwksClient.getSigningKey(header.kid, (err, key) => {
              callback(err, key ? key.getPublicKey() : null);
            });
          },
          (err, decoded) => {
            if (err) {
              reject(err);
            } else {
              resolve(decoded);
            }
          }
        );
      });
    }
    
    /**
     * Validate a webhook coming from impact.com.
     *
     * @param webhookBody The raw text of the webhook body.
     * @param jwsNoPayloadHeader The value of the X-Hook-JWS-RFC-7797 header.
     */
    export function validateAdvocateWebhook(
      webhookBody: string,
      jwsNoPayloadHeader: string
    ): Promise<object> {
      const webhookBodyBase64 = Base64.encodeURI(webhookBody);
      const token = jwsNoPayloadHeader.replace("..", "." + webhookBodyBase64 + ".");
      return validateWithAdvocateJwks(token);
    }
    
    using System;
    using System.Linq;
    using System.Net.Http;
    using System.Runtime.Caching;
    using System.Threading;
    using System.Threading.Tasks;
    using Jose;
    
    public class JwksExample
    {
    
        private static readonly string jwksUrl = "https://app.referralsaasquatch.com/.well-known/jwks.json";
        private readonly ObjectCache jwkCache = new MemoryCache("advocate_jwk_cache");
        private readonly SemaphoreSlim jwkCacheSemaphore = new SemaphoreSlim(1, 1);
    
        private async Task<Jwk> GetAdvocateJwkByKid(string kid)
        {
            {
                if (jwkCache[kid] is Jwk jwkFound)
                {
                    return jwkFound;
                }
            }
            await jwkCacheSemaphore.WaitAsync();
            try
            {
                { // Double checked lock
                    if (jwkCache[kid] is Jwk jwkFound)
                    {
                        return jwkFound;
                    }
                }
                string jwksString;
                using (var httpClient = new HttpClient())
                {
                    jwksString = await httpClient.GetStringAsync(jwksUrl);
                }
                var jwks = JwkSet.FromJson(jwksString, JWT.DefaultSettings.JsonMapper);
                var jwk = jwks.FirstOrDefault(jwk => jwk.KeyId.Equals(kid));
                if (jwk == null)
                {
                    throw new Exception("JWK not found for kid");
                }
                jwkCache.Set(kid, jwk, DateTimeOffset.UtcNow.AddDays(1));
                return jwk;
            }
            finally
            {
                jwkCacheSemaphore.Release();
            }
        }
    
        /// <summary>
        /// Validate the given JWT with the public JWKS and get the claims.
        /// </summary>
        /// <param name="token">The input JWT</param>
        /// <returns>The validated payload JSON string</returns>
        public async Task<string> ValidateWithAdvocateJwks(string token)
        {
            var headers = JWT.Headers(token);
            var kid = headers["kid"] as string;
            var jwk = await GetAdvocateJwkByKid(kid);
            return JWT.Decode(token, jwk);
        }
    
        /// <summary>
        /// Validate a webhook coming from impact.com.
        /// </summary>
        /// <param name="webhookBody">The raw bytes of the webhook body.</param>
        /// <param name="jwsNoPayloadHeader">The value of the X-Hook-JWS-RFC-7797 header.</param>
        /// <returns>The validated webhook JSON string</returns>
        public Task<string> ValidateAdvocateWebhook(byte[] webhookBody, string jwsNoPayloadHeader)
        {
            var webhookBodyBase64 = Base64Url.Encode(webhookBody);
            var token = jwsNoPayloadHeader.Replace("..", '.' + webhookBodyBase64 + '.');
            return ValidateWithAdvocateJwks(token);
        }
    
    }
    
    

Verify the webhook's IP address

Your Advocate program sends webhooks from one of the following IP Addresses. You can rely on this list for adding additional security, but we still recommend validation via JWS as your primary security mechanism.

  • 35.202.24.73
  • 35.222.215.196
  • 35.236.200.194
  • 35.186.188.88

🚧

Important

These are not all the IP addresses in use, only those relating to webhooks. Do not rely on this list for making calls to the API, using the SDKs, or Portal.


Reference

Signature generation process

The X-Hook-JWS-RFC-7797 signature is a JWS with a detached payload. It is a string that looks like JWSHeader..JWSSignature.

The signature generation works as follows:

  1. Webhook data is generated.
    • Example: A reward.created webhook.
  2. The payload is signed using one of the keys from our JWS key set.
    • Example: kid: 94ab304d-c90a-45ba-80e4-b4516a57a1c8
  3. The JWS header will contain some standard properties:
    • kid: The key used to sign the request. This can be looked up in the JWKS.
    • alg: RS256
    • typ: JWT
  4. The JWS is added as the X-Hook-JWS-RFC-7797 header to the webhook request.
  5. The HTTP Request is sent as a POST to all the webhook endpoints subscribed.

Cryptography standards

JSON Web Signature (JWS) represents content secured with digital signatures or Message Authentication Codes (MACs) using JSON-based data structures. The JWS cryptographic mechanisms provide integrity protection for an arbitrary sequence of octets.

JSON Web Token (JWT) is a a compact, URL-safe means of representing claims to be transferred between two parties. Essentially, it is a JWS structure with a JSON object as the payload, enabling the claims to be digitally signed, MACed, or encrypted.

JSON Web Key Set (JWKS) is a standard for sharing crytographic keys. Our JWKS contains the public keys of the public/private key pairs used for asymmetric encryption. That means that it is signed with a private key known only to impact.com, but that the signature can be verified by anyone using the matching public key from the JWKS.

Sample webhook content

Accept-Encoding: gzip,deflate,br
Content-Type: application/json; charset=UTF-8
Content-Length: 543
Connection: keep-alive
X-Hook-JWS-RFC-7797: eyJraWQiOiIzZDMxM2JjOC1hYjNiLTRmM2MtYWJiNy0zN2I4NGE0MmQwZGEiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..DQfCOrdudxqz4r7uiCAhyKIi4bGZignWmr1ct_7Bf6DXmgwUciQJaQTvYffc5lni9K6DqclQG0cfI6X5pqceeFays1_atEP-bsN6w_0krjKg72rcVHKecgEOlFNhsF0xfYdjoY-5z-tpzpjOU1QBKOl7eE8K9AkCL5FDg6Huu26Ov1TcmEGhNMSN7UW0zBNXvNsjeRfO57dKgtA-6wyl3TUcsxYsz81Q3Og0dprMfNBr-bcqvs4aHUUxLmU013RYXAdQmK395NvN54YJniZcsy8svF1THExp4WkmOw9WmX_kHUhsvadTegAI4PbGYx9h1xIcdV_IrfuzUV1Ta9WfKg
X-Hook-Signature: h2JX9dV4o1r2sJypeVBIWOqW0as=

{
    "id": "5dfaadc9d132f00f8b742288",
    "type": "reward.created",
    "tenantAlias": "a5kz4dlxt403z",
    "live": true,
    "created": 1576709577227,
    "data": {
        "type": "CREDIT",
        "id": "577405e3e4b0cc57c1e2e684",
        "dateCreated": 1467221475151,
        "dateScheduledFor": null,
        "dateGiven": 1467221475151,
        "dateExpires": 1475170275151,
        "dateCancelled": null,
        "accountId": [[example account ID]], 
        "userId": [[example user ID]],
        "cancellable": true,
        "rewardSource": "FRIEND_SIGNUP",
        "programId": null,
        "unit": "%",
        "assignedCredit": null,
        "redeemedCredit": null,
        "name": null,
        "currency": null,
        "redemptions": null
    }
}