Deleting the permission doesn't remove it
This week I built an onboarding and offboarding tracker for a client. When someone joins or leaves, a row goes into a SharePoint list, and an n8n workflow fans it out into the 20-odd tasks that have to happen. A second workflow runs every morning at 7:30 and emails whoever has fallen behind.
Both workflows talk to Microsoft 365 through Microsoft Graph, as an app registration with its own secret. That app needs to do exactly two things: write to three lists on one SharePoint site, and send email from one mailbox. I wanted it able to do those two things and nothing else, so that a leaked secret is an annoyance rather than a breach.
Getting there took a day. It looked finished four times before it was.
One site, not every site
The usual Graph permission for SharePoint is Sites.ReadWrite.All, which means
every site in the tenant. The narrow one is Sites.Selected: the app starts
with access to nothing, and you grant it sites one at a time.
The catch is that making that grant needs a much bigger permission. So the app
briefly held Sites.FullControl.All, used it to grant itself write access to
the HR site, and then that role had to go.
I removed it in the Entra portal, under App registrations, API permissions. The row disappeared. Done.
It wasn’t. That page lists what the app asks for. What it has actually been
given is a separate object, an app role assignment on the app’s service
principal, and deleting the request doesn’t touch it. Tokens kept carrying
Sites.FullControl.All while the portal said it was gone. The real revoke
lives under Enterprise applications, on the app’s Permissions page (or the
“Revoke admin consent” button).
That cost a session, and it would have cost a lot more if I’d believed the portal.
Test both halves
The only way to know is to ask the API, not the portal, and to ask two questions:
- Can it do the thing it’s for? That should succeed.
- Can it do something it shouldn’t? That should fail.
An over-permissioned app passes the first question perfectly. Asking only that one is the easiest way to sign off an app that isn’t scoped at all.
Under the final scope, on a freshly minted token:
| Request | Result |
|---|---|
| The HR site | 200 |
| Another site | 403 |
| The root site | 403 |
GET /sites?search=* | 403 |
There’s a trap in there too. Plain GET /sites returns 200 under
Sites.Selected, with an empty list. That looks like a leak and isn’t: the app
is allowed to ask, it just sees nothing. An earlier run of my own check flagged
it as a failure. The request that actually catches an over-permissioned app is
the search, which returns 403 when the app is scoped and 200 when it isn’t.
Mail.Send means any mailbox
The morning reminders need to send email, and nothing in the tenant could. So
the app got Mail.Send.
As an application permission, Mail.Send means send as anyone: any mailbox
in the tenant. On an app I’d just spent a day narrowing to one site, it would
have been the widest permission it held.
I checked both halves before going any further. Sending as the IT mailbox: 202.
Sending as a staff member: also 202. The probe was addressed to the IT mailbox
with saveToSentItems off, so nothing landed in anyone’s inbox, but the
capability was real.
The fix lives in Exchange, not Entra. An application access policy restricts an app’s mail permissions to the members of one mail-enabled group:
New-ApplicationAccessPolicy -AppId <app-id> `
-PolicyScopeGroupId lifecycle-senders@contoso.com `
-AccessRight RestrictAccess
The group holds one mailbox.
The check that tells you what you want to hear
Exchange ships a cmdlet for testing these policies, Test-ApplicationAccessPolicy.
A few minutes after creating the policy, I asked it about the staff member’s
mailbox. It said Denied.
Real sends as that staff member were still returning 202.
The cmdlet reads the policy’s configuration. It tells you what you asked for, not what the send path is doing, and the send path hadn’t caught up yet. It’s the portal mistake again, one layer over: a place that describes the rule, mistaken for a place that enforces it.
Four no’s, not one
Later that afternoon I ran the probe properly: a token minted in a standalone script, five mailboxes, every message addressed to the IT mailbox so a surprise success would land nowhere.
| Send as | Result |
|---|---|
| The IT mailbox | 202 |
| A staff member | 403 |
| That staff member’s admin account | 403 |
| Two other staff | 403 each |
Every 403 carried the same error:
ErrorAccessDenied: Access to OData is disabled: [RAOP] :
Blocked by tenant configured AppOnly AccessPolicy settings
Four negatives, because one or two can’t tell “scoped to the wrong mailbox” apart from “no policy at all”. And the error text, because a 403 for some other reason looks identical in a status column.
Insisting on every target being real caught a near miss. My first probe looked up a user by their email address, and Graph said 404. Graph finds users by their sign-in name or their object id, never by an email alias, and this person’s sign-in name is on a different domain from the address everyone emails them at. Their mail works fine. But a probe aimed at a user Graph can’t find proves nothing, and at a glance that failure would have read as “blocked”. Now every negative target gets looked up in the directory before its 403 counts.
Every layer caches
After Mail.Send was granted, the morning workflow still failed with a 403 on
the send, while a token I minted by hand seconds earlier worked. n8n keeps the
access token inside its stored credential and reuses it until it expires. The
one it held had been minted 19 minutes before the grant, without Mail.Send.
It fixes itself within the hour. I could have spent that hour debugging a
permission that was already right.
That makes three caches that have caught me this way. Entra hands out cached tokens for a few minutes, the tool I use to query Graph keeps its own, and now n8n. After any permission change, neither a 200 nor a 403 means anything until you’ve minted a fresh token yourself, outside the tool you’re testing.
One nuance: the access policy isn’t in the token. Exchange checks it at send time against the app and the mailbox, so a stale token doesn’t explain policy lag. Mint fresh anyway. It costs nothing, and guessing wrong about which layer decides is the whole mistake.
Last, the real thing. I seeded one throwaway task due that day, ran the morning workflow through n8n’s own credential, and watched it send one digest: 202. Then I deleted the task. With nothing due, the workflow never reaches its send step, so until then the send path hadn’t actually been tested.
The checklist
- Revoke the grant, not the request. Enterprise applications, not App registrations.
- Test both halves. In scope must succeed and out of scope must fail.
- Use at least two negative targets, and confirm they exist before you trust their 403.
- Record the error code, not just the status.
- Mint your own fresh token after any change.
- A cmdlet that reads config isn’t an enforcement test. Only a real call is.
- Make risky probes harmless. Address test sends to the allowed mailbox,
with
saveToSentItemsoff.
The app can now write to one site and send as one mailbox. It took a day to be sure of that, and most of the day went on not believing the things that said it was already done.