Skip to content
CloudOps
Newsletter
All guides
AI for Microsoft Teams By James Joyner IV · · 11 min read

Validate Graph Change Notifications and Decrypt Resource Data

Microsoft Graph webhooks demand a validation handshake and optional encrypted payloads. Here is how to handle both correctly so your Teams automation never misses an event.

  • #microsoft-teams
  • #microsoft-graph
  • #webhooks
  • #security
  • #automation

The first time I stood up a Microsoft Graph change-notification subscription for a Teams channel, my endpoint returned a clean 200 OK and Graph still refused to create the subscription. The error was a terse Subscription validation request failed. I had skipped the validation handshake, assuming a healthy HTTP status was enough. It is not. Graph has its own contract, and if you do not meet it precisely, you get no events at all — silently.

This guide walks through the two things every production notification endpoint must get right: the validation token handshake, and decrypting resource data when you ask Graph to include the actual message payload. I leaned on an AI assistant to scaffold the crypto plumbing, but I want to be clear up front: treat the model like a fast junior engineer. It will happily generate code that decrypts a payload, and it will just as happily skip the signature check that proves the payload actually came from Microsoft. A human reviews this before it ever touches a real tenant, and you never hand the model live tenant credentials or certificate private keys.

The validation handshake

When you create or renew a subscription, Graph immediately sends a POST to your notificationUrl with a validationToken query parameter. You must echo that token back in the response body, as text/plain, within 10 seconds. Miss it and the subscription is rejected.

app.post("/graph/notifications", express.json(), (req, res) => {
  const validationToken = req.query.validationToken;
  if (validationToken) {
    // Echo it back verbatim, plain text, HTTP 200.
    res.set("Content-Type", "text/plain");
    return res.status(200).send(validationToken);
  }
  // Otherwise this is a real notification batch — handle below.
  handleNotifications(req, res);
});

The token is URL-encoded, so let your framework decode the query string. Do not re-encode it. The most common failure I see is sending JSON ({"validationToken": "..."}) instead of the raw string. Graph wants the bare value.

Pro Tip: Graph also sends this same validation POST on every subscription renewal, not just creation. If your renewal job runs at 3am and your endpoint changed, you will lose the subscription with no events to tell you. Alert on subscription expiry, not just on notification volume.

Validating that notifications actually came from Graph

Once the subscription exists, real notifications arrive as a batch. Each one carries the clientState you set at creation time. Compare it on every request — it is your shared secret against spoofed POSTs to your public endpoint.

function handleNotifications(req, res) {
  // Acknowledge fast (202) so Graph does not retry; process async.
  res.status(202).send();

  for (const note of req.body.value) {
    if (note.clientState !== process.env.GRAPH_CLIENT_STATE) {
      console.warn("Rejected notification: clientState mismatch");
      continue;
    }
    processNotification(note);
  }
}

clientState alone is enough for change notifications that only tell you “something changed, go fetch it.” But if you opted into encrypted resource data, there is a stronger signature to check, covered next.

Requesting and decrypting resource data

By default a Teams message notification does not include the message body — you get an ID and a hint to call Graph again. That extra round-trip costs latency and throttling budget. You can instead set includeResourceData: true at subscription time and supply an encryption certificate. Graph then encrypts the payload with a symmetric key, encrypts that key with your certificate’s public key, and ships both in the notification.

{
  "changeType": "created",
  "notificationUrl": "https://ops.example.com/graph/notifications",
  "resource": "/teams/{team-id}/channels/{channel-id}/messages",
  "includeResourceData": true,
  "encryptionCertificate": "<base64 public cert>",
  "encryptionCertificateId": "ops-notify-2026",
  "expirationDateTime": "2026-06-19T00:00:00Z",
  "clientState": "rotate-me-quarterly"
}

Decryption is a two-step unwrap. First, RSA-decrypt the dataKey with your certificate’s private key to recover the AES key. Then AES-CBC-decrypt the data using that key, with the first 16 bytes of the key as the IV.

const crypto = require("crypto");

function decryptResourceData(encryptedContent, privateKeyPem) {
  // 1. Unwrap the symmetric key with the cert private key (OAEP).
  const symmetricKey = crypto.privateDecrypt(
    {
      key: privateKeyPem,
      padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
    },
    Buffer.from(encryptedContent.dataKey, "base64")
  );

  // 2. VERIFY the signature before trusting the payload.
  const expectedSig = crypto
    .createHmac("sha256", symmetricKey)
    .update(Buffer.from(encryptedContent.data, "base64"))
    .digest("base64");
  if (expectedSig !== encryptedContent.dataSignature) {
    throw new Error("dataSignature mismatch — possible tampering");
  }

  // 3. AES-CBC decrypt; IV is the first 16 bytes of the symmetric key.
  const iv = symmetricKey.subarray(0, 16);
  const decipher = crypto.createDecipheriv("aes-256-cbc", symmetricKey, iv);
  const decrypted = Buffer.concat([
    decipher.update(Buffer.from(encryptedContent.data, "base64")),
    decipher.final(),
  ]);
  return JSON.parse(decrypted.toString("utf8"));
}

The dataSignature check in step 2 is the part an AI draft will often omit, because the payload “works” without it. It does not work safely without it. The signature is an HMAC over the encrypted data keyed by the symmetric key, and it is your proof that Microsoft — and only Microsoft — produced this payload. Skip it and you have built a public endpoint that will decrypt and trust whatever someone posts.

Where the private key lives

The encryption certificate’s private key never goes into source, never into an environment variable in plaintext, and absolutely never into a prompt. Keep it in Azure Key Vault and pull it at runtime with managed identity. When I prototype with AI help, I generate a throwaway self-signed cert for local testing and let the model see only the public half. The real private key for the tenant is provisioned by a human through Key Vault, separately.

Renewal and expiry are part of the design

Channel message subscriptions max out around an hour of lifetime, so you need a renewal loop that runs well inside the window — I renew at the halfway mark. Pair it with Graph lifecycle notifications so that if a subscription is reauthorizationRequired or missed, you get told instead of discovering it through a quiet incident channel.

If you want a head start on the prompts I used to scaffold the handshake and decryption, the structured ones in my prompt packs save the back-and-forth, and you can iterate on them live in the prompt workspace. I drafted the initial decryption helper with Claude and then hardened the signature check by hand.

Conclusion

Graph change notifications are reliable once you respect the contract: echo the validation token as raw text, check clientState on every batch, and — if you decrypt resource data — verify dataSignature before you trust a single byte. Let AI accelerate the boilerplate, but make the security-critical checks something a human signs off on, and keep the private keys out of both your repo and your prompts. For more Teams automation patterns, browse the Microsoft Teams category.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.