When Mail.Send Becomes a Systemic Risk, and How to Contain It
Case study: regaining control over a Graph permission Microsoft does not scope finely enough.
The moment it goes wrong
Tuesday, 2:17 PM. A client billing and follow-up application, deployed three years ago in an 800-employee professional services firm, maintained by an external vendor, integrated with the internal CRM, starts sending emails. A lot of emails.
In twenty minutes, 12,000 messages go out from billing@firm.ca to the complete list of active and past clients. Subject: "Important update regarding your account." Body: a link to a domain nobody recognizes, pointing to a credential-harvesting page styled in the firm's branding.
The application's client secret had just been compromised on a developer workstation at the vendor's office.
The app had Mail.Send granted as an application permission. From Microsoft Graph's perspective, everything was compliant: valid token, granted permission, well-formed calls. Exchange Online did its job. All 12,000 messages were delivered, signed from a legitimate firm mailbox, to clients who had trusted that address for years.
The cost of the incident is not in the 12,000 emails. It is in the clients who will click, in the breach notification obligation under privacy law, in the regulatory investigation, and in the months of reputation rebuilding that follow.
Why Microsoft doesn't block this scenario
Mail.Send as an application permission is tenant-wide by design. The app can send as anyone, from any mailbox, to any recipient, at any rate. That is per the documentation, and that is the problem.
Microsoft does offer a scoping mechanism, ApplicationAccessPolicy in Exchange Online. In practice:
- Configuration happens via PowerShell, often outside the scope of the admin managing the application.
- Scoping is limited to authorized source mailboxes via a security group.
- Nothing controls recipients, content, volume, temporal context, or caller identity.
- Audit boils down to "the app sent an email."
- Modifying the policy in production carries non-trivial operational risk.
Put differently: Microsoft gives you a binary switch, the app can or cannot send from this mailbox. What is missing is an application firewall. The same gap applies across the whole family of high-privilege Graph permissions, as covered in the Graph API permissions blind spot.
What the incident looks like with a perimeter in place
Same scenario, but this time sendMail calls are routed through a governance proxy between the application and Graph. The traffic is unchanged from Microsoft's side. Every call is evaluated against a set of declarative rules before being forwarded.
Baseline: default-deny. Any call that matches no allow rule is blocked and logged. The rules below add explicit, cumulative authorizations, and all must pass for a call to reach Graph.
Rule 1 — Who is allowed to send?
- name: "Billing — caller authorized"
endpoint_pattern: "/users/*/sendMail"
http_methods: [POST]
conditions:
all_of:
- caller_identity:
application_id: "app-client-billing"
- object_attribute:
path: "userPrincipalName"
pattern: "^billing@firm\\.ca$"
action: allow
If another application tries to call sendMail from this mailbox, or if the billing app tries to send from a different mailbox, the request is rejected before it reaches Graph.
Rule 2 — Recipients bound to business perimeter
- name: "Billing — only known client domains"
endpoint_pattern: "/users/*/sendMail"
conditions:
all_of:
- domain:
field: "message.toRecipients[].emailAddress.address"
source: "custom_attribute:client_domains_whitelist"
action: allow
The client domain list is maintained dynamically from the CRM. Sending to a domain not on the active list, including consumer mail providers or arbitrary domains, is blocked. The attacker can no longer hijack the legitimate mailbox to reach massive recipient lists.
Rule 3 — Rate limiting
- name: "Billing — rate limit safety net"
endpoint_pattern: "/users/*/sendMail"
conditions:
all_of:
- rate_limit:
max: 200
window_seconds: 3600
scope: "caller_identity"
action: allow
200 sends per hour, calibrated to actual billing volume. Beyond that, the application is automatically cut off. The 12,000 emails from our original scenario become 200, a contained incident instead of a crisis.
Rule 4 — Content filtering
- name: "Billing — no external links in body"
endpoint_pattern: "/users/*/sendMail"
conditions:
any_of:
- regex_match:
field: "message.body.content"
pattern: "https?://(?!firm\\.ca|client-portal\\.firm\\.ca)"
action: block
Any non-whitelisted external URL in the message body triggers a block. The classic exfiltration pattern, hijacking a legitimate mailbox to send phishing links, becomes structurally impossible.
Rule 5 — Temporal window
- name: "Billing — business hours only"
endpoint_pattern: "/users/*/sendMail"
conditions:
all_of:
- time_window:
timezone: "America/Montreal"
days: ["Mon", "Tue", "Wed", "Thu", "Fri"]
hours: "08:00-18:00"
action: allow
A client billing app has no legitimate reason to send at 2 AM on a Sunday. Out-of-window activity becomes a strong, blocking alert signal.
These five rules are part of the full declarative rule grammar in GraphWarden, which covers caller identity, object attributes, content, rate, time window, and network context.
See the rule documentation →What changes for the decision-maker
Without a governance layer, Mail.Send risk is structurally carried by the application, therefore by the vendor, by a contract, by a liability clause that gets litigated after the incident.
With a governance layer, the risk is carried by a declarative rule: readable by a security analyst, auditable in real time, modifiable without application redeployment, and producing an audit trail independent of the vendor.
Three shifts happen.
- The perimeter moves. Control is no longer inside code the vendor maintains, but inside configuration the customer owns.
- Audit becomes actionable. Every
sendMailcall, blocked, filtered, or allowed, generates a structured event queryable outside the Microsoft tenant. In an incident, the evidence does not need to be reconstructed from Exchange logs. - Incident response drops from days to minutes. Stopping abuse means editing a rule. No vendor ticket, no maintenance window, no redeployment.
For organizations bound by professional confidentiality or privacy regulations, this shift has a direct consequence in regulatory investigations or civil litigation: the posture moves from "we trusted the vendor" to "we applied a documented, auditable technical control."
The limits worth naming
This approach only works if the application calls Graph through the proxy. If the app retains its own direct Microsoft credentials, it can bypass the governance layer.
That is why the trust model matters as much as the filtering itself. In a correctly designed architecture, the application never holds the real Graph secrets. It receives a proxy credential, useless anywhere except behind the governance layer. The actual secret stays in the customer's vault, accessible only to the proxy via Managed Identity. Bypass becomes structurally impossible, not just contractually prohibited.
Second limit: latency. A sendMail call routed through the proxy adds roughly 20 to 50 ms. Invisible for transactional workloads, worth discussing for high-volume bulk scenarios, rarely an issue for client billing or business notification volumes.
The takeaway
Mail.Send as an application permission is not a Microsoft design flaw. It is a legitimate permission whose scoping was left to the ecosystem. Exchange Online provides the switch. The ecosystem needs to provide the firewall.
For any organization whose reputation rests on client trust, professional firms, financial institutions, public sector, ISVs distributing M365 applications, the question is no longer if an application will drift, but when, and at what scale. Placing a governance layer between applications and Graph reduces the scale of an incident by an order of magnitude.
That is exactly why GraphWarden exists.