# Three Base64 Blobs and a Null Token: Decrypting Graph Rich Notifications

- Date: 2026-10-05
- Author: Jeppe Spanggaard
- Description: Learn how to validate and decrypt Microsoft Graph rich notification payloads in C#, including the appRoleAssignmentRequired trap that makes validationTokens null.
- URL: https://jeppe-spanggaard.dk/blogs/graph-rich-notifications-validate-decrypt/
- Tags: csharp, Microsoft Graph, Authentication, Webhooks

## TL;DR

Prove the batch is from Microsoft by validating every JWT in `validationTokens` (signature keys from your tenant's OIDC metadata, issuer your tenant, audience your app id) and checking `clientState`. Then decrypt in three steps: RSA-unwrap the AES key from `dataKey` with your certificate's private key (OAEP), verify `dataSignature` as an HMAC-SHA256 over the encrypted `data` *before* decrypting, then AES-CBC-decrypt with the IV taken from the first 16 bytes of the key. If `validationTokens` is null, your service principal has "Assignment required" turned on.


Microsoft Graph rich notifications are great: the webhook payload carries the resource data itself, so you can act on a change without calling back. What the docs undersell is that the payload arrives as three base64 blobs (`data`, `dataKey`, `dataSignature`) plus a set of JWTs, and undoing that envelope is entirely your job.

My first attempt got a `null` where the JWTs should be, and my second got ciphertext I couldn't decrypt. Both failures were one configuration flag and one crypto convention away from working, so here's the whole pipeline in one place.

## First, Prove It's Microsoft

Anyone who discovers your notification URL can POST to it. Rich notification batches ship signed JWTs in `validationTokens`, and all of them must validate before you touch the content:

```csharp
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
    $"https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration",
    new OpenIdConnectConfigurationRetriever());

var parameters = new TokenValidationParameters
{
    ValidAudiences = new[] { clientId, "https://graph.microsoft.com" },
    ValidIssuers = new[]
    {
        $"https://login.microsoftonline.com/{tenantId}/v2.0",
        $"https://sts.windows.net/{tenantId}/"     // tokens still show up v1-style
    },
    ConfigurationManager = configManager           // fetches + caches signing keys
};

foreach (string token in validationTokens)
{
    var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, parameters);
    if (!result.IsValid) return false;             // one bad token fails the batch
}
```

On top of that, check that each notification's `clientState` equals the secret you set at subscription time. And when a batch fails validation, still answer 202 - you're telling Graph "delivered", not "approved", and a 4xx just buys you a retry storm of the same bad batch.

## Then, Undo the Envelope

Graph encrypts each payload with a fresh AES key, and encrypts *that key* with the public certificate you provided when subscribing. Your job is the reverse, in exactly this order:

```csharp
public string Decrypt(string data, string dataKey, string dataSignature, X509Certificate2 cert)
{
    byte[] encryptedKey = Convert.FromBase64String(dataKey);
    byte[] encryptedData = Convert.FromBase64String(data);
    byte[] expectedSignature = Convert.FromBase64String(dataSignature);

    // 1. Recover the single-use AES key with our private key.
    using RSA rsa = cert.GetRSAPrivateKey()!;
    byte[] symmetricKey = rsa.Decrypt(encryptedKey, RSAEncryptionPadding.OaepSHA1);

    // 2. Verify integrity BEFORE decrypting.
    using (var hmac = new HMACSHA256(symmetricKey))
    {
        byte[] actual = hmac.ComputeHash(encryptedData);
        if (!CryptographicOperations.FixedTimeEquals(actual, expectedSignature))
            throw new CryptographicException("Data signature mismatch.");
    }

    // 3. AES-CBC-PKCS7. The IV is the first 16 bytes of the key itself.
    using var aes = Aes.Create();
    aes.Key = symmetricKey;
    aes.Mode = CipherMode.CBC;
    aes.Padding = PaddingMode.PKCS7;
    aes.IV = symmetricKey[..16];

    using var decryptor = aes.CreateDecryptor();
    byte[] plaintext = decryptor.TransformFinalBlock(encryptedData, 0, encryptedData.Length);
    return Encoding.UTF8.GetString(plaintext);     // the resource JSON
}
```

**What's happening here?**

1. `dataKey` is the AES key, RSA-encrypted with your cert's public key. OAEP padding, and each notification item can carry a different key - never cache it.
2. The HMAC check runs on the *encrypted* bytes, before decryption. If someone tampered with the ciphertext, you find out without feeding attacker-controlled bytes to your AES code. `FixedTimeEquals` keeps the comparison from leaking timing.
3. There's no separate IV field anywhere in the notification. The convention is that the first 16 bytes of the symmetric key double as the IV - miss that and you get either garbage or a padding exception, with nothing pointing at the cause.

The result is plain JSON for the resource, shaped by whatever `$select` you put on the subscription.

## The Null Token Trap

My actual first failure: `validationTokens` was `null` on every batch, so nothing could ever validate.

The cause is an Entra setting nowhere near your webhook code. For rich notifications, the app's service principal must have `appRoleAssignmentRequired = false` (Enterprise applications, your app, Properties, "Assignment required?" = No). Alternatively, keep it on and explicitly assign an app role to the *Microsoft Graph Change Tracking* service principal (appId `0bf30f3b-4a52-48df-9a82-234910c4a086`). Do neither, and Graph silently sends null tokens and your endpoint rejects every batch it was built to receive.

## Gotchas

- **Two things are named "validation token".** The plain-text `validationToken` query parameter you echo back during the subscription handshake, and the JWT array `validationTokens` on every rich batch. Different formats, different purposes, one letter apart.
- **Basic notifications have no tokens at all.** If the same endpoint also receives non-rich subscriptions, don't demand JWTs from those batches; there `clientState` is your only check.
- **Plan for two certificates during rotation.** Old subscriptions keep encrypting with the old cert for a while. Pick the cert by each notification's `encryptionCertificateId` instead of assuming the newest, and retire the old one only when it stops appearing.
- **The cert can be self-signed.** Graph only uses the public key for encryption and never checks the issuer. RSA, 2048 to 4096 bits.

## Wrapping Up

Treat a rich notification as untrusted input until it has passed three gates: JWTs validated, `clientState` matched, HMAC verified - and only then decrypt (RSA-unwrap, then AES-CBC with the key's first 16 bytes as IV). And if the tokens come back null, stop debugging your code and go flip "Assignment required" on the service principal.

