Blocking sign-ins from countries you never operate in is a high-value, low-effort Conditional Access control — it removes a huge slice of opportunistic attack traffic for the cost of one policy. The catch is legitimate travel. The moment someone flies somewhere outside your allowed regions, that same policy locks them out of their own mailbox. Handle exclusions manually and you're stuck fielding "I'm in Singapore and can't log in" tickets at 2 a.m., adding people to an exclusion group, and — the part everyone forgets — remembering to remove them when they get back. Every forgotten removal is a standing hole in the control.
The whole problem reduces to one primitive: a Conditional Access exclusion group. You attach it to the geo-block policy; anyone in the group bypasses the block. So the automation is just two events — add on travel start, remove on return — done reliably, at scale, without a human in the loop. Everything below is plumbing around those two events.
I've built this twice, on two tenants, and the interesting part is that they use different execution models. Comparing them is the real lesson here, so we'll build both.
01The core: a group, and two events
Add on start, remove on end. The rest is how you schedule those two calls.
Membership changes go through Microsoft Graph. Authenticate with an app registration (client-credentials flow), then it's two calls against the group:
# get a token (client credentials) POST https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token grant_type=client_credentials&scope=https://graph.microsoft.com/.default # add a user to the exclusion group POST https://graph.microsoft.com/v1.0/groups/{group-id}/members/$ref { "@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/{user-object-id}" } # remove them on return DELETE https://graph.microsoft.com/v1.0/groups/{group-id}/members/{user-object-id}/$ref
The app registration needs GroupMember.ReadWrite.All (or a scoped equivalent). That's the entire Graph surface. Now — when do you make those two calls? That's the architecture decision.
02Approach A — one long-running instance per request
Simple, stateless, no infrastructure. Great until it isn't.
The most direct design: a single flow, triggered per request, that just waits. Here it's fed by a Microsoft Form — the responder submits their travel dates, and the flow parks itself until each date arrives:
# Microsoft Forms intake — read the response fields UPN = Get_response_details → responder Start = Get_response_details → <start-date question> End = Get_response_details → <end-date question> # then the flow literally waits Delay Until {Start} → add user to group → send "activated" email Delay Until {End} → remove from group → send "completed" email
That's the whole flow. No database, no scheduler, no reconciliation logic. Delay Until suspends the flow instance — potentially for weeks — then resumes it exactly at the travel boundary. It's elegant for what it is, and for a small tenant with occasional travel it's honestly enough.
But it has real limits, and they're the kind you only feel later. Each request is a separate parked instance, so there's no single place to see who's currently excluded or who's scheduled. If someone changes their dates, you've got an orphaned instance still holding the old schedule. If a run fails or the plan resets, a parked instance can be lost silently — and a lost removal means someone stays excluded indefinitely. It works, but it's fragile in exactly the ways a security control shouldn't be.
03Approach B — state table plus a reconciler
More moving parts, dramatically more robust. This is the one I'd run in production.
The durable design splits the problem in two: an intake flow that just records the request as state, and a reconciler that runs on a schedule and continuously makes reality match that state. It's the same pattern Kubernetes uses — desired state in a store, a loop that converges toward it.
Intake just parses the request and writes a row. When the request arrives as an approved-ticket email, parsing is string surgery on the body; when it's a Form, you read the response fields directly:
# email intake — pull fields out of the message body
UPN = concat(split(split(body, 'User email: ')[1], '@')[0], '@contoso.com')
Start = substring(body, add(indexOf(body, 'Start Date: '), 12), 10)
End = substring(body, add(indexOf(body, 'End Date: '), 10), 10)
Then the reconciler runs every few hours, pulls every travel record, and for each one decides what to do based purely on today's date versus the stored window. Crucially it also checks current group membership first, which makes it idempotent — running it twice does no harm, and it self-heals from any missed run.
That catch-up branch is the whole reason this design beats the parked-instance one. There is no state that can be "lost" — if the group and the table ever disagree, the next reconciler pass fixes it. On removal, the record is deleted from the table, so the table only ever holds active-or-future travel.
04The one-record-per-user trap
A subtle schema bug — and the fix that comes for free.
Azure Table Storage enforces uniqueness on the PartitionKey + RowKey pair. The obvious first design uses the user's UPN as the RowKey — one row per person. It works until someone has two trips on the books. The second insert collides with the first, and you silently can't store overlapping or back-to-back travel for the same user.
The fix is to make the key encode the trip, not just the person:
# before — one active record per user, collisions on a second trip PartitionKey = "Users" RowKey = user@contoso.com # after — one record per (user, travel window): many trips per user PartitionKey = user@contoso.com RowKey = "2026-08-04_2026-08-10" # start_end
Building that RowKey is a one-line compose on intake — concatenate the two parsed dates with an underscore:
# intake: build the composite RowKey from the parsed dates RowKey = concat(Travel_Start_Date, '_', Travel_End_Date) # → "2026-08-04_2026-08-10"
Now uniqueness is per (user, window), so one person can hold as many non-identical trips as they like. And there's a bonus: the RowKey is the date range, so the reconciler can split it on the underscore to get the start and end without even reading the other columns. The key does double duty — uniqueness constraint and payload at once.
05Which one should you build?
The honest trade-off.
| Aspect | Delay-Until (per request) | Table + reconciler |
|---|---|---|
| Infrastructure | None — one flow | A storage table + two flows |
| Visibility of pending travel | None — state hidden in parked instances | One table you can query at a glance |
| Handles edits / cancellations | Poorly — orphaned instances | Update or delete the row |
| Resilience to a missed / failed run | A lost instance can strand a user | Self-heals on the next cycle |
| Multiple trips per user | Fine (separate instances) | Fine (composite key) |
| Timing precision | To the minute | Within the reconciler interval (e.g. 4h) |
| Best for | Small tenants, low volume | Anything you have to defend or audit |
The Delay-Until version is the right call when travel is rare and you want zero infrastructure. But for anything you'll be asked to account for — where "who is currently exempt from the geo-block, and why?" needs a one-second answer — the reconciler wins, because the answer is a table you can read. Security controls should be legible. A pile of suspended flow instances is not.
An exclusion group is a hole you opened on purpose
Whatever design you pick, the exclusion group weakens the control for whoever's in it — so the automation's most important job is the removal, not the add. Alert on the group's membership, cap how long a single exclusion can last, and make sure a failed removal is loud, not silent. The reconciler's self-healing removal is a security property, not just a convenience.
The pattern
A CA exclusion group plus add-on-start, remove-on-end automation turns a brittle manual exception into a self-service, self-healing control. Store the desired state and reconcile toward it — the version you can query is the version you can defend.
Further reading
- Microsoft Graph — add group memberthe members/$ref add / remove calls
- Conditional Access — common policieslocation-based blocks and exclusions
- Azure Table Storage — design guidancePartitionKey / RowKey and composite keys
- Power Automate documentationtriggers, Delay Until, and HTTP actions
Comments
Questions or corrections welcome. Sign in with GitHub to join the thread.