Azure Governance

Policy Exemptions Done Right: Waiver vs Mitigated, Scopes, and Expiry

Six months after you roll out a deny policy that blocks public storage accounts, a senior engineer pings you: “Your governance broke our deploy. The legacy export job needs a public account for a partner who can’t do private endpoints yet. Security signed off, give us a pass.” You have three ways to give that pass, and two are wrong. You could weaken the policy (now everyone can go public). You could add the resource group to the assignment’s notScopes — an exclusion — and the account vanishes from compliance entirely, as if the rule never existed, with no record of why and no expiry. Or you can grant a policy exemption: the resource stays in scope, the policy still knows about it, it shows in the dashboard as Exempt rather than non-compliant, it carries a category that says whether you accepted the risk or fixed it another way, and it expires on a date you set. Only the third option is governance. The other two are how a clean compliance posture quietly rots into a lie.

This article is the mental model for getting that third option right. Azure Policy exemptions are the supported, auditable way to say “this specific scope is allowed to skip this specific assignment, for this reason, until this date” — without touching the policy, without weakening it for anyone else, and without making the resource disappear. The whole feature turns on a handful of distinctions that people conflate and then regret: exemption versus exclusion (waived-but-tracked versus never-evaluated), Waiver versus Mitigated (the two and only two exemption categories, and they mean genuinely different things to an auditor), scope (an exemption lives at a management group, subscription, resource group, or single resource and only ever narrows, never widens), and expiry (the one field that separates a disciplined carve-out from technical debt that outlives the people who created it).

By the end you will reach for an exemption only when it’s the right tool, set its category honestly, scope it as tightly as the exception demands, give it an expiry every time, target only the controls inside an initiative that need waiving, and read the dashboard knowing exactly what “Exempt” means. You’ll also know the traps cold — the exemption that silently outlived its expiresOn, the one that waived forty controls because someone exempted the initiative instead of one policyDefinitionReferenceId, the DeployIfNotExists remediation an exemption quietly stopped from running, and the RBAC gap that let a team self-exempt their own non-compliance.

What problem this solves

Policy at scale runs into reality. You assign a security baseline — deny public IPs on NICs, require HTTPS-only on web apps, enforce a specific set of allowed regions, audit untagged resources — across a management group, and within a week you have a fistful of legitimate exceptions: a break-glass jump box that genuinely needs a public IP, a vendor appliance that can’t do the tag schema, a data-residency-exempt workload that must run in a region your policy bans, a deprecated app scheduled for decommission next quarter that will never be brought into compliance. Every one of these is real. The question is what you do with them, and the wrong answers all share one trait: they erase the exception from the record.

What breaks without exemptions: people get the carve-out by damaging the control. They edit allowedValues to slip one region through (now the whole org can use it). They drop a resource group into notScopes so the assignment never sees it (the resource shows compliant-by-omission, indistinguishable from one that genuinely passes, forever). They set the effect to Disabled “just while we sort this out” and never re-enable it. Each makes the dashboard look green while the posture degrades, and none records who approved the exception, why, or when it ends. Six months later an auditor asks “why is this account public?” and the honest answer is “nobody remembers, and our tooling actively hid it.”

Who hits this: every team past the toy stage of governance. The moment you move from auditing to enforcing (Deny, Modify, DeployIfNotExists), exceptions become unavoidable — a hard deny with no exemption path gets ripped out the first time it blocks a legitimate Friday deploy. It bites hardest in landing-zone environments where a platform team sets policy at the management-group level and dozens of application teams need scoped, temporary relief without filing a ticket to weaken a shared control. The fix is not “make the policy softer” — it’s “grant a narrow, categorised, expiring exemption, and let the dashboard keep telling the truth.”

To frame the whole field before the deep dive, here is the menu of ways people grant an exception, and what each one actually does to your compliance posture:

Mechanism What it does Resource shows as Recorded reason? Expires? Verdict
Policy exemption Suppresses this assignment for this scope, tracked Exempt Yes (category + metadata) Yes (expiresOn) The right tool
Exclusion (notScopes) Removes the scope from the assignment entirely (not evaluated — “compliant” by omission) No No Use only for permanent structural carve-outs
Edit the policy / allowedValues Changes the rule for everyone under it Compliant (rule now permits it) No No Wrong — weakens the control globally
Set effect to Disabled Turns the policy off Not started / not evaluated No No Wrong — blinds the whole assignment
Lower the assignment to Audit Stops enforcing, keeps reporting Non-compliant (but not blocked) No No Wrong as an exception tool (it’s a posture choice)

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already understand Azure Policy at the level of: a policy definition is a rule with an effect (Audit, Deny, Modify, DeployIfNotExists, and so on); an initiative (policy set) bundles many definitions under one assignment; and an assignment binds a definition or initiative to a scope (a management group, subscription, or resource group), at which point everything under that scope is evaluated. If those terms are fuzzy, read Azure Policy Effects Decoded: Deny vs Audit vs Modify vs DeployIfNotExists and Policy Definitions vs Initiatives: When to Bundle Controls into a Set first — exemptions only make sense once assignments do.

You should be comfortable running az in Cloud Shell, reading JSON, and you need write permission on policy exemptions at the scope in question (covered in Security notes). Knowing the Management Groups hierarchy helps, because exemption scope and assignment scope both live on that tree and their relationship is the crux of the whole feature.

This sits in the Governance track, downstream of policy authoring and assignment. It is the operational half of policy: assignment is how you set the rules, exemptions are how you run the rules in the real world where exceptions exist. It pairs tightly with compliance reporting — an exemption’s entire reason for existing is to keep the compliance dashboard honest — and with Azure RBAC Fundamentals, because who may grant an exemption is itself a governance control. Here is the quick map of where each piece lives and who owns it:

Layer What lives here Who usually owns it Relevance to exemptions
Management group Policy + initiative assignments, org-wide Platform / cloud governance Exemptions here waive broadly — handle with care
Subscription Workload-level assignments, exemptions Platform + workload lead Common scope for “this whole sub is exempt from X”
Resource group App-level resources, scoped exemptions Application team The usual scope for a real, narrow exception
Single resource One offending resource Application team Tightest, safest exemption scope
RBAC (write on exemptions) Who may grant a carve-out Governance team The gate that stops self-service non-compliance

Core concepts

Five mental models make every later decision obvious.

An exemption is a tracked waiver, not a deletion. When you exempt a scope from an assignment, the resource is still in scope — the policy still knows it exists — but evaluation is suppressed and its compliance state becomes Exempt instead of compliant or non-compliant. This is the most important distinction in the whole feature. An exclusion (notScopes on the assignment) does the opposite: it removes the scope from the assignment so the resource is never evaluated at all, then reports as compliant-by-omission, indistinguishable from one that genuinely passes. Exemption keeps the exception visible and accountable; exclusion makes it invisible. You want visible.

The category is a statement to an auditor, and there are exactly two. Every exemption carries an exemptionCategory of precisely Waiver or Mitigated — no third value exists. Waiver means “we accept this non-compliance as a known risk for now” (the work isn’t done; we live with the gap, usually temporarily). Mitigated means “the control’s intent is met by other means, so the check here is a false positive” (a compensating control exists). Choosing right isn’t bureaucracy — a Waiver tells an auditor “open risk, track it”; a Mitigated tells them “satisfied elsewhere, here’s the compensating control.” Mislabel them and you either alarm auditors over handled risks or hide real ones.

Scope only narrows; an exemption can never grant more than its assignment. An exemption targets one policy assignment at a scope that must be at or below that assignment’s scope. If a deny policy is assigned at a management group, you can exempt a child subscription, a resource group inside it, or a single resource — but you cannot “un-assign” the policy above the assignment, and an exemption at one RG has no effect on a sibling RG. Exemptions flow down like assignments do: one at a subscription covers everything under it; one at a single resource covers only that resource. You are always subtracting a sub-tree from an assignment’s reach, never adding to it.

Expiry is a first-class field, and crossing it re-arms the policy automatically. An exemption can carry expiresOn — a UTC timestamp after which it stops applying. The moment it passes, the resource is evaluated against the assignment again on the next cycle: if still non-compliant it goes back to Non-compliant (and a Deny effect blocks new writes again). The object itself isn’t deleted — it lingers as an expired exemption to clean up — but its effect ends. expiresOn is optional in the schema, which is exactly the trap: an exemption with no expiry is permanent, and permanent exceptions are how governance rots. Treat the field as mandatory even though the platform doesn’t.

An exemption can waive a whole assignment or just specific controls within an initiative. When the assignment is an initiative (a set of, say, forty definitions), an exemption by default waives the entire initiative for the scope — almost never what you want. The policyDefinitionReferenceIds field lets you list the specific reference IDs of the definitions this exemption applies to, leaving the rest enforced. Exempting “require HTTPS-only” for one legacy app should not also exempt “encrypt at rest,” “enforce TLS 1.2,” and every other control in the baseline. Granular reference targeting is what keeps an exemption surgical.

The vocabulary in one table

Before the deep sections, pin down every moving part. The glossary repeats these for lookup; this is the mental model side by side:

Concept One-line definition Where it lives Why it matters
Policy assignment A definition/initiative bound to a scope On a scope (MG/sub/RG) The thing an exemption targets
Exemption A tracked, scoped, expiring waiver of an assignment Microsoft.Authorization/policyExemptions The right tool for a real exception
Exclusion (notScopes) A scope removed from the assignment entirely Assignment property Resource never evaluated — use sparingly
exemptionCategory Waiver or Mitigated — why the exemption exists Exemption property The statement to an auditor
Scope (of exemption) MG / sub / RG / resource the waiver applies to Exemption’s scope Must be at/under the assignment scope
expiresOn UTC time the exemption stops applying Exemption property Separates discipline from rot
policyDefinitionReferenceIds Which definitions in an initiative to waive Exemption property Keeps initiative exemptions surgical
Exempt (compliance state) The dashboard state of an exempted resource Compliance results Visible, accountable exception
assignmentScopeValidation Whether the exemption scope must be in the assignment’s reach Exemption property Guards against mis-scoped waivers
Resource Policy Contributor Built-in role that can write exemptions RBAC role definition The permission gate

Exemption vs exclusion vs the wrong fixes

This is the fork everyone gets wrong, so spend a minute here before anything else. Four mechanisms can make a resource “stop failing a policy,” and they are profoundly different in what they do to your records. The table is the whole argument; the prose explains the rows that bite.

Mechanism How you do it What the policy does Compliance result Audit trail Blast radius
Exemption Create a policyExemptions object at the scope Stops evaluating that scope for that assignment Exempt Category + metadata + (ideally) expiry Exactly the scope you set
Exclusion Add scope to the assignment’s notScopes Never evaluates that scope at all Not evaluated (looks compliant) None on the resource The whole sub-tree you excluded
Weaken the policy Change allowedValues / rule logic Permits the thing for everyone Compliant (rule changed) Policy version history only Every scope the definition applies to
Disable the effect Set effect to Disabled Turns the policy off Not started Assignment history only The entire assignment

Exemption vs exclusion is “tracked” vs “vanished.” An exclusion via notScopes is the right call for a permanent, structural carve-out where the resource has no business being evaluated — a dedicated sandbox subscription you’ve deliberately put outside a production-grade baseline. It’s the wrong call for a temporary or risk-bearing exception, because an excluded resource looks identical to a compliant one — nothing on it to review, no category, no expiry, no reason. If you ever want to report “here are the things we’re knowingly letting slide and why,” you need exemptions. Cleanly put: exclusion answers “this is not in the population we govern”; exemption answers “this is in the population, and here’s our documented exception for it.”

Weakening the policy or disabling the effect is never an exception tool. Both change the rule for the whole assignment, not one resource. Editing allowedValues to let one workload use southindia opens that region to every subscription under the assignment; setting the effect to Disabled blinds the policy entirely. People reach for these because they’re fast and make the red go away, but they trade one resource’s exception for an org-wide hole and leave no trace on the resource that needed it. Where you find these, they’re almost always an exemption done wrong.

The decision is mechanical once you frame it as two questions:

If the exception is… …and the resource should be… Use
Temporary or risk-bearing Visible in compliance as a known exception Exemption (with expiresOn)
Permanent and structural Genuinely outside the governed population Exclusion (notScopes)
Actually a rule that’s too strict for everyone Reflected in the policy itself Fix the policy/initiative (not an exception)
A control you no longer want anywhere Removed from enforcement Re-scope or remove the assignment

Waiver vs Mitigated: the two categories

There are exactly two exemption categories, and the choice is a semantic claim, not a formality. Get it right and your dashboard tells auditors the truth; get it wrong and you either cry wolf or hide a real gap.

Dimension Waiver Mitigated
What it claims “Known non-compliance, accepted for now” “Control intent met by other means”
Is the risk open? Yes — the gap is real and untreated No — a compensating control handles it
Typical trigger Legacy app, pending fix, business deadline Equivalent control at another layer, false positive
What an auditor should do Track it, chase the remediation, watch expiry Verify the compensating control, then accept
Should it expire? Almost always — it’s temporary by nature Often, but may be longer-lived if the control is stable
Danger if mislabelled Hides a real, untreated risk as “handled” Floods the risk register with non-issues

Use Waiver when the work isn’t done and you’ve decided to live with the gap — for now. The policy is right, the resource genuinely fails it, nobody claims otherwise, and you’re accepting the risk on a timer. The legacy export job needing a public storage account because the partner can’t do private endpoints yet: Waiver. The deprecated app that won’t get TLS 1.2 because it’s being decommissioned in Q3: Waiver. A Waiver tells the auditor “yes, real gap, here’s the ticket and the date we expect it closed.” A Waiver without an expiry is a contradiction — you’ve “accepted the risk for now” with no end to “now.”

Use Mitigated when the policy would flag something that’s actually fine because it’s handled elsewhere. The control’s intent is satisfied by a compensating control at another layer, so the check here is a false positive. A policy demands a resource-level firewall, but the workload sits behind a centralized Azure Firewall enforcing the same restriction more strictly: intent met, so Mitigated. A policy requires a per-resource diagnostic setting, but you collect the same telemetry through a platform-level pipeline: Mitigated. A Mitigated says “don’t chase this — the goal is achieved, just not the way this check looks for.” The discipline that makes it honest is naming the compensating control in the metadata so an auditor can verify the claim rather than take it on faith.

The failure modes are symmetric and both corrosive:

Mistake What it looks like Why it’s harmful The fix
Labelling a real gap Mitigated “We’ll call it mitigated to make it look handled” Hides untreated risk; auditor never chases it Use Waiver; record the remediation plan
Labelling a handled control Waiver Every false positive becomes “accepted risk” Risk register fills with non-issues; real ones drown Use Mitigated; name the compensating control
Either category with no expiry A “temporary” exception with no end Becomes permanent debt nobody revisits Always set expiresOn; review before it lapses
Either category with no description A waiver with no “why” Nobody can later justify or retire it Mandate a description / linked ticket in metadata

Scope: where an exemption lives and how far it reaches

An exemption’s scope is the resource container (or single resource) the waiver applies to, and it obeys one iron rule: it must sit at or below the scope of the assignment it exempts, and it only ever subtracts from that assignment’s reach. Get the scope right and the carve-out is surgical; get it too broad and you’ve quietly waived a control for a whole subscription when one resource needed it.

Exemption scope Waives the assignment for… Use when Risk if chosen carelessly
Management group Every subscription/resource under that MG Org-wide structural exception (rare) Enormous blast radius — waives everything below
Subscription Every RG/resource in that subscription A whole workload/environment is exempt Exempts resources you didn’t mean to
Resource group Every resource in that RG An app’s resources share one exception Future resources in the RG inherit the waiver
Single resource Just that one resource One specific offender (the common case) Tightest, safest — prefer this by default

Default to the single-resource scope and widen only with a reason. The instinct to exempt a resource group “to be safe” is exactly backwards: an RG-scoped exemption silently covers every current and future resource in that group, so the account you exempt today is joined next month by three more that inherit the waiver without anyone deciding they should. If only one resource has the exception, scope to that one resource. Widen to RG or subscription only when the exception genuinely belongs to the whole container (e.g. a whole non-production subscription waived from a production-grade tagging initiative), and write why in the description.

The exemption scope must be reachable by the assignment, and assignmentScopeValidation enforces it. You cannot exempt a scope the assignment doesn’t cover — exempting a resource group from an assignment on a different subscription does nothing, because the assignment never reached that RG. The assignmentScopeValidation property defaults to validating exactly this: the platform checks your exemption scope falls within the assignment’s effective scope and rejects mis-scoped ones. A DoNotValidate option exists for unusual cases (e.g. creating an exemption ahead of an assignment), but turning validation off is a foot-gun — it lets you create exemptions that point at nothing, sitting in inventory looking meaningful while doing nothing. Leave validation on unless you have a documented reason.

A worked example of how scope flows down. Suppose a “deny public IP on NICs” policy is assigned at the mg-corp management group:

You create an exemption at… Effect
mg-corp (the assignment’s own scope) Every NIC under the entire org is exempt — almost certainly wrong
sub-platform (a child subscription) Every NIC in that subscription is exempt — wrong unless the whole sub needs it
rg-breakglass (one RG) Every NIC in that RG is exempt — fine if the RG is purpose-built for break-glass
nic-jumpbox-01 (one resource) Only that NIC is exempt — the surgical, correct choice for one jump box
rg-in-a-different-MG No effect — outside the assignment’s reach; rejected by scope validation

Expiry: the field that separates discipline from rot

expiresOn is the most important optional field in the entire feature, and the word “optional” is the danger. An exemption with no expiry is a permanent hole that outlives the incident that created it, the engineer who created it, and often the workload itself. Treat it as mandatory by team policy.

expiresOn is a UTC timestamp, and crossing it ends the exemption’s effect, not its existence. You set it to an ISO-8601 UTC time (e.g. 2026-12-31T23:59:59Z). Until that instant, the scope is Exempt. At the next evaluation cycle after it passes, evaluation resumes: the resource is judged again, and if it still fails it flips back to Non-compliant — and if the effect is Deny, new or updated resources at the scope are blocked again as if the exemption never existed. The object doesn’t delete itself; it remains as an expired exemption (the portal flags it; it appears in listings) that you should remove as housekeeping.

Behaviour at and after expiresOn What actually happens
The instant it passes Nothing immediate — exemptions act on the next evaluation cycle, not in real time
Next evaluation cycle The scope is evaluated against the assignment again as if un-exempted
If still non-compliant Compliance flips back to Non-compliant
If the effect is Deny New/updated resources at the scope are blocked again
The exemption object Remains as an expired exemption (not auto-deleted) — clean it up
Re-evaluation timing Standard policy evaluation runs roughly every 24 hours; trigger sooner with an on-demand scan

There is no built-in “warn me before it expires” — you build that. Azure does not email you when an exemption is about to lapse. A forgotten “temporary” exemption silently re-arms a Deny policy and breaks a deploy on the day it expires. The discipline is to query for exemptions nearing expiry on a schedule (Resource Graph or a scheduled script) and review them before the date — renew with fresh justification if still needed, or let it lapse on purpose to force the fix. The expiry isn’t a nuisance; it’s the forcing function that keeps exceptions from becoming permanent.

The expiry-hygiene rules that keep this clean:

Rule Why
Set expiresOn on every exemption An exception with no end becomes permanent debt
Keep windows short for Waivers (weeks–months) A Waiver is accepted risk — it should pressure the fix
Allow longer windows for Mitigated (if the control is stable) The intent is genuinely met; less urgency, but still review
Alert on exemptions within N days of expiry Azure won’t warn you — build it (Resource Graph + schedule)
Remove expired exemptions as housekeeping They linger and clutter inventory; an expired one isn’t doing its job
Renew deliberately, with fresh justification Re-creating it forces someone to re-affirm the exception still applies

How exemptions interact with effects

An exemption suppresses evaluation, which means it touches different effects in different ways. This is where “I exempted it but the remediation still didn’t run” and “I exempted it but the deploy is still blocked” come from. The matrix:

Effect What an exemption does to it The practical consequence
Deny Stops the deny from blocking writes at the exempt scope Resources that would be blocked can now be created/updated
Audit Stops the non-compliant finding for the exempt scope The resource shows Exempt, not non-compliant
AuditIfNotExists Suppresses the “related thing is missing” finding No non-compliant flag for the missing related resource
Modify The modification is not applied at the exempt scope Tags/properties the policy would set are not set
DeployIfNotExists The remediation deployment does not run for the exempt scope The thing DINE would deploy (a diagnostic setting, an agent) is not deployed
Append The appended value is not added at the exempt scope The field the policy would add is left as-is
Manual The attestation state is effectively waived for the scope Shows Exempt rather than requiring attestation

The DeployIfNotExists trap is the one that quietly hurts. People think of exemptions only as “stop blocking me” (Deny) or “stop nagging me” (Audit). But a DeployIfNotExists policy creates things — a diagnostic setting, the Azure Monitor agent, a backup configuration — and an exemption on its scope means that deployment never happens. Exempt a subscription from a DINE policy that deploys diagnostic settings, and resources there silently stop getting their diagnostics wired up. You wanted to skip a check; you actually skipped a deployment. When you exempt a scope, enumerate the assignment’s effects and ask “am I okay with the Modify and DINE actions also not happening here?” — often you’re not, and the right move is a tighter policyDefinitionReferenceIds that waives only the Audit/Deny control.

Exemptions do not retroactively undo what Modify/DINE already did. If a Modify policy already added a tag, or a DINE policy already deployed a diagnostic setting, an exemption afterward does not remove it — it only stops future enforcement. Exemptions are forward-looking suppression, not a rollback. To undo an already-applied action you change the resource yourself; the exemption just stops the policy from re-applying it.

Targeting specific controls in an initiative

When the assignment is an initiative, an exemption’s default behaviour — waiving the entire set — is almost always too broad. policyDefinitionReferenceIds is the field that makes initiative exemptions surgical, and using it well is the difference between “exempt one app from one HTTPS check” and “accidentally exempt one app from your whole forty-control security baseline.”

Every definition inside an initiative has a policyDefinitionReferenceId. When you build an initiative, each member definition gets a reference ID (a string you choose, like requireHttpsOnly, or a GUID) that uniquely identifies it within that initiative; you find these in the initiative’s policyDefinitions array. An exemption’s policyDefinitionReferenceIds is a list of those IDs: include the ones to waive, and the exemption applies only to those, leaving the rest enforced.

policyDefinitionReferenceIds value Effect on the scope
Omitted / empty The entire initiative is exempt for the scope — rarely what you want
["requireHttpsOnly"] Only the HTTPS-only control is waived; the other definitions still apply
["requireHttpsOnly", "requireTls12"] Those two controls are waived; everything else still applies
A reference ID that doesn’t exist in the initiative The exemption matches nothing for that ID (typo = silent no-op)

The silent-typo failure cuts both ways. Misspell a reference ID and the exemption applies to nothing (no error for the bad ID) — so the control you meant to waive stays enforced, your deploy stays blocked, and you waste an hour wondering why the exemption “didn’t work.” Conversely, omit the list intending to waive one control and you’ve waived the whole initiative — far worse, because forty controls just went Exempt and your dashboard quietly went green on things still non-compliant. Always pull the exact reference IDs from the initiative definition, and after creating the exemption confirm that only the intended controls show Exempt.

To get the reference IDs reliably, read them straight off the initiative:

# List the reference IDs and the underlying definitions in an initiative (policy set)
az policy set-definition show --name "security-baseline" \
  --query "policyDefinitions[].{ref:policyDefinitionReferenceId, def:policyDefinitionId}" -o table

Then build the exemption against just those IDs (shown in the lab below). The rule of thumb: an exemption on an initiative assignment should almost always carry a non-empty policyDefinitionReferenceIds. Reserve the whole-initiative exemption for the rare case where a scope genuinely needs relief from all of it.

Architecture at a glance

Picture the policy world as a tree and an exemption as a small, dated label you pin onto one branch. At the top sits your management-group hierarchy; an assignment (a deny policy, or a forty-control initiative) attaches at some node and its enforcement flows downward to every subscription, resource group, and resource beneath. Evaluation is constant: every resource under the assignment is continuously judged into one of a few compliance states — Compliant, Non-compliant, Exempt, Conflicting, or Not started.

An exemption is a separate object (Microsoft.Authorization/policyExemptions) pinned onto a node at or below the assignment’s node. It names the assignment it modifies, carries a category (Waiver or Mitigated), optionally lists the specific reference IDs within an initiative it applies to, and optionally carries an expiresOn. Conceptually it draws a small box around its sub-tree and tells the engine “for this assignment — and, if listed, only these controls — don’t judge what’s in this box; mark it Exempt.” Everything outside is judged normally; siblings of the exempt branch are untouched.

Now walk a request through it. A NIC is created in rg-breakglass. The engine sees it’s under mg-corp, where “deny public IP” is assigned, so the deny would fire and block the create. But there’s an exemption pinned at rg-breakglass, category Waiver, expiresOn three weeks out, scoped to this assignment — so the deny is suppressed, the NIC is created, and it shows as Exempt (not compliant, not blocked, exempt, with category and reason attached). Three weeks later the clock passes expiresOn; on the next cycle the box dissolves, the NIC is judged again, the deny re-arms, and any new public NIC in that RG is blocked once more while the existing one flips to Non-compliant. That dashed, dated box — narrow in scope, honest in category, finite in time — is the whole mental model. An exclusion, by contrast, is a hole cut out of the tree: no box, no label, no date; the branch simply isn’t part of the assignment and nothing on it ever shows up to be reasoned about.

Hold the three box-defining properties together: scope sets how big it is, category sets what it means, expiresOn sets how long it lasts. Lose any one and you lose the discipline — too-wide scope waives too much, a mis-categorised box lies to the auditor, an expiry-less box never goes away.

Real-world scenario

Northwind Logistics ran a CAF-aligned landing zone: a platform team owned a Security-Baseline initiative — 38 controls including deny-public-storage, require-https-only, audit-untagged, enforce-allowed-regions, and a DeployIfNotExists that wired diagnostic settings to a central Log Analytics workspace — assigned at the mg-corp management group. It enforced cleanly for a year. Then the Partner-Integrations team hit a wall: a legacy EDI export had to write to a public storage account because a freight partner’s ancient system couldn’t consume a private endpoint, and the rollout deadline was two weeks out. The deny blocked every deploy.

The on-call engineer’s first instinct was the fast fix: drop rg-edi-export into the assignment’s notScopes. It worked instantly — the deploy went green — and it was exactly wrong. The RG vanished from the dashboard; the public account looked identical to a compliant one; there was no record that security had reviewed and accepted it; and there was no expiry, so when the partner did migrate to a private endpoint nine months later, nobody removed the exclusion. The RG silently stayed outside the baseline — including outside the other 37 controls — so when an engineer later spun up an untagged, diagnostics-less VM in it, no policy said a word.

The governance lead caught it in a quarterly review and replaced it with a policy exemption, with four deliberate choices. Scope: the single storage account, not the RG — so the rest of rg-edi-export snapped back under all 38 controls immediately (and that untagged VM lit up red). Category: Waiver — accepted risk, not a compensating control; the account really was public. Reference IDs: policyDefinitionReferenceIds set to just denyPublicStorage, leaving the other 37 — including the diagnostic-settings DINE — fully enforced on that very account, so it still got its diagnostics wired up. Expiry: expiresOn 90 days out, tied to the partner’s committed migration date, with a linked change ticket.

The payoff showed three months later. A scheduled Resource Graph query flagged the exemption as expiring in 7 days; the partner had migrated, the public account was gone, and the team let the exemption lapse instead of renewing it. The dashboard never lied: the account showed Exempt (with category and ticket) the whole time it existed, then the exemption expired into nothing because the resource it covered was already retired. One over-broad exclusion that hid a public account and 37 unrelated controls for nine months, swapped for one surgical, categorised, 90-day exemption that hid exactly one control on one resource and announced itself before it lapsed. The fix was never “weaken the baseline” — it was “carve the exception narrowly, honestly, and on a timer.”

Advantages and disadvantages

Advantages Disadvantages
Exception stays visible as Exempt (not hidden) Adds objects to manage; un-reviewed, they accumulate
Category records why (Waiver vs Mitigated) Mislabelling a category corrupts the risk picture
expiresOn forces a natural review/retirement Expiry is optional — easy to omit and create permanent debt
Narrow scope (down to one resource) limits blast radius Over-broad scope (RG/sub) silently covers future resources
policyDefinitionReferenceIds waives only the needed controls Omit it on an initiative and you waive everything
Keeps the policy/initiative unchanged for everyone else Suppresses Modify/DINE actions, not just checks (surprises)
RBAC-gated — you can control who may grant one Loose RBAC lets teams self-exempt their own non-compliance
Auditable and reportable via Resource Graph No native “expiring soon” alert — you must build it

When the advantages matter most: in any enforced (Deny/Modify/DINE) governance program at scale, where exceptions are inevitable and you need them tracked rather than hidden. The visibility and category turn “we have exceptions” from a liability into a defensible, auditable position. When the disadvantages bite: when exemptions are created ad hoc with no expiry, no description, over-broad scope, and loose RBAC — at which point they become a second shadow governance system that quietly unwinds the first. The feature is only as good as the discipline around expiresOn, scope, and who may write them.

Hands-on lab

A free-tier-friendly walk-through: assign an audit policy (so nothing is blocked or costs money), watch a resource go non-compliant, exempt it as a Mitigated carve-out with an expiry, confirm it shows Exempt, then tear down. Audit effect means this is safe to run in any subscription.

1. Set variables and pick a resource group.

RG="rg-exemption-lab"
LOC="centralindia"
SUB=$(az account show --query id -o tsv)
az group create --name "$RG" --location "$LOC"

2. Assign a built-in audit policy that flags storage accounts allowing public blob access. (Built-in definition: “Storage account public access should be disallowed” — an Audit policy, nothing is blocked.)

# Look up the built-in definition id by display name
DEF_ID=$(az policy definition list \
  --query "[?displayName=='Storage account public access should be disallowed'].id | [0]" -o tsv)

# Assign it at the resource group scope (audit only)
az policy assignment create \
  --name "audit-public-blob" \
  --display-name "Audit public blob access (lab)" \
  --policy "$DEF_ID" \
  --scope "/subscriptions/$SUB/resourceGroups/$RG"

3. Create a storage account that will trip the audit (allowing blob public access), then trigger an on-demand evaluation so you don’t wait ~24h:

SA="stexlab$RANDOM"
az storage account create --name "$SA" --resource-group "$RG" \
  --location "$LOC" --sku Standard_LRS --allow-blob-public-access true

# Trigger an on-demand compliance scan for the resource group
az policy state trigger-scan --resource-group "$RG"

After the scan, confirm it’s non-compliant:

az policy state list --resource-group "$RG" \
  --query "[?complianceState=='NonCompliant'].{res:resourceId, state:complianceState}" -o table

4. Create the exemption — category Mitigated (pretend a central control handles public-access risk), scoped to just that storage account, with an expiry:

ASSIGNMENT_ID=$(az policy assignment show --name "audit-public-blob" \
  --scope "/subscriptions/$SUB/resourceGroups/$RG" --query id -o tsv)
SA_ID=$(az storage account show --name "$SA" --resource-group "$RG" --query id -o tsv)

az policy exemption create \
  --name "exempt-lab-storage" \
  --display-name "Lab storage exemption (Mitigated)" \
  --policy-assignment "$ASSIGNMENT_ID" \
  --exemption-category Mitigated \
  --scope "$SA_ID" \
  --expires-on "2026-12-31T23:59:59Z" \
  --description "Public-access risk handled by central network control; lab demo. Ticket GOV-1234."

5. Re-scan and confirm the resource now shows Exempt rather than non-compliant:

az policy state trigger-scan --resource-group "$RG"

az policy state list --resource-group "$RG" \
  --query "[].{res:resourceId, state:complianceState}" -o table
# The storage account's state should now read 'Exempt'

You can also list the exemption and read its category and expiry back:

az policy exemption list --scope "$SA_ID" \
  --query "[].{name:name, category:exemptionCategory, expires:expiresOn}" -o table

6. Bicep equivalent of the exemption (for the IaC path — note the symbolic reference to the assignment and the scope via scope):

// Exemption authored as code, scoped to a single resource (the storage account)
resource exemption 'Microsoft.Authorization/policyExemptions@2022-07-01-preview' = {
  name: 'exempt-lab-storage'
  scope: storageAccount            // any resource/RG/sub/MG symbol or extension scope
  properties: {
    displayName: 'Lab storage exemption (Mitigated)'
    policyAssignmentId: auditAssignment.id
    exemptionCategory: 'Mitigated'                       // 'Waiver' or 'Mitigated'
    expiresOn: '2026-12-31T23:59:59Z'                    // UTC; omit = permanent (avoid)
    assignmentScopeValidation: 'Default'                 // keep validation on
    // For an INITIATIVE assignment, waive only specific controls:
    // policyDefinitionReferenceIds: [ 'denyPublicStorage' ]
    description: 'Public-access risk handled by central network control. Ticket GOV-1234.'
  }
}

7. Teardown (removes the exemption, assignment, and the whole RG):

az policy exemption delete --name "exempt-lab-storage" --scope "$SA_ID"
az policy assignment delete --name "audit-public-blob" \
  --scope "/subscriptions/$SUB/resourceGroups/$RG"
az group delete --name "$RG" --yes --no-wait

You’ve now created the full lifecycle: assign → fail → exempt (categorised, scoped, expiring) → verify Exempt → remove. Swap the Audit policy for a Deny one and the only change is that step 3 would be blocked until the exemption in step 4 exists.

Common mistakes & troubleshooting

The failure modes that cost the most time, each as symptom → root cause → how to confirm → fix. These are the ones that turn a five-minute carve-out into a half-day mystery or a silent governance hole.

# Symptom Root cause Confirm with Fix
1 Created an exemption but the resource still shows non-compliant Evaluation hasn’t re-run yet az policy state list still old; no recent scan az policy state trigger-scan; wait for the cycle
2 Exemption “did nothing” on an initiative Wrong/typo’d policyDefinitionReferenceIds Diff IDs against az policy set-definition show Use exact reference IDs from the initiative
3 Exempted one control, but the whole baseline went Exempt Omitted policyDefinitionReferenceIds on an initiative Compliance shows all controls Exempt for the scope Re-create with the specific reference ID(s)
4 Deploy started failing again “out of nowhere” The exemption’s expiresOn passed az policy exemption list shows it expired Renew with fresh justification, or fix the resource
5 DeployIfNotExists stopped wiring up diagnostics for a scope Exemption suppressed the DINE action, not just a check Resources in scope missing the deployed config Scope the exemption to only the Audit/Deny ref, not DINE
6 Exemption create rejected / has no effect Scope is outside the assignment’s reach assignmentScopeValidation error; wrong sub/MG Exempt a scope at/under the assignment’s scope
7 “Temporary” exemption is two years old No expiresOn was ever set expiresOn is null in the object Add an expiry; build an expiring-soon alert
8 A team waived their own non-compliance They hold write on exemptions at their scope az role assignment list shows the grant Restrict exemption-write to governance; review grants
9 Excluded RG silently dropped all controls, not one Used notScopes (exclusion) instead of an exemption Assignment’s notScopes contains the RG Remove from notScopes; create a scoped exemption
10 Auditor flags “handled” items as open risk Real gaps mislabelled, or no description/category rationale Exemptions are Waiver with no compensating note Re-categorise honestly; add description/ticket

The expanded reasoning for the ones that bite hardest:

2 & 3 — the initiative reference-ID pair. Two sides of one coin. A wrong reference ID (typo, or a GUID not in this initiative) matches nothing — the control stays enforced, your deploy stays blocked, and the exemption looks broken. Omitting the list on an initiative waives all of it — the deploy unblocks but thirty-nine other controls just went Exempt, which is more dangerous because it looks like success. Always read the exact IDs off az policy set-definition show, set them explicitly, and verify afterward that only the intended controls show Exempt.

4 & 7 — the expiry pair. Symptom 4 (“deploy broke for no reason”) is almost always a forgotten expiresOn that passed — confirm with az policy exemption list --query "[?expiresOn < '<now>']" and either renew it deliberately or fix the resource. Symptom 7 is the opposite rot: no expiry was ever set, so a two-week carve-out is now a two-year hole. Both fixes point the same way: every exemption gets an expiresOn, and you run a scheduled query for ones nearing it.

5 — the DeployIfNotExists suppression. The subtle one. You exempted a scope to silence an audit, but the same assignment also carries a Modify or DINE effect, and the exemption suppressed those actions too — so diagnostic settings, agents, or tags the policy would deploy silently stopped. Confirm by checking whether resources in the scope are missing the thing DINE deploys. Fix by scoping the exemption (via policyDefinitionReferenceIds) to only the Audit/Deny definition, leaving the remediating one enforced.

8 — self-service exemptions. If application teams hold write on policyExemptions at their own scope, they can exempt themselves from the controls meant to govern them — a self-graded exam. Confirm with az role assignment list --scope <scope> looking for Microsoft.Authorization/policyExemptions/write (often via Resource Policy Contributor or inherited Owner/Contributor). Fix by reserving exemption-write to a governance group and routing exception requests through review.

To find every exemption (including expired) across the estate, Resource Graph is the fastest lens:

policyresources
| where type == "microsoft.authorization/policyexemptions"
| extend cat = tostring(properties.exemptionCategory),
         expires = todatetime(properties.expiresOn),
         assignment = tostring(properties.policyAssignmentId)
| project name, scope=id, cat, expires, assignment
| order by expires asc

Run that on a schedule and you have the “what have we waived, why, and when does it lapse” report that auditors and your future self will thank you for.

Best practices

Security notes

Cost & sizing

Exemptions themselves are freeMicrosoft.Authorization/policyExemptions objects carry no charge, and neither does Azure Policy evaluation. The cost dimension here is not rupees; it’s governance overhead and risk cost, and they’re real even if they don’t appear on an invoice.

Rough sense of the operational footprint, since there’s no bill to right-size:

“Cost” dimension What drives it How to keep it small
Exemption objects Number of live exceptions Tight scope + expiry so they self-retire
Review overhead Exemptions created without process Request→review→IaC pipeline; PR-reviewed
Expiry monitoring No native alerting One scheduled Resource Graph query (negligible)
Audit/risk exposure Mis-categorised or no-expiry exemptions Mandate category + description + expiresOn
Azure charges None — exemptions and evaluation are free

Interview & exam questions

1. What is the difference between a policy exemption and an exclusion (notScopes)? An exemption keeps the resource in scope but suppresses evaluation, marking it Exempt with a category and (ideally) an expiry — the exception stays visible and accountable. An exclusion removes the scope from the assignment via notScopes, so the resource is never evaluated and reports compliant-by-omission with no record. Use exemptions for tracked, temporary, or risk-bearing exceptions; exclusions only for permanent structural carve-outs.

2. What are the two exemption categories and when do you use each? Waiver and Mitigated — the only two values. Waiver means accepted, known non-compliance (a real, untreated gap, usually temporary) — use it when the work isn’t done and you’re living with the risk on a timer. Mitigated means the control’s intent is met by a compensating control elsewhere, so the check is a false positive — use it when the goal is genuinely achieved another way, and name that control.

3. An exemption is assigned at a resource group, but the policy is assigned at a different subscription. What happens? Nothing — it has no effect because its scope is outside the assignment’s reach. An exemption must target a scope at or below the assignment’s scope, and assignmentScopeValidation (default on) rejects or flags a mis-scoped exemption. Exemptions only narrow an assignment’s reach; they can’t reach a scope the assignment never covered.

4. You exempt a subscription from an initiative to silence one audit finding, and diagnostic settings stop getting deployed. Why? The initiative includes a DeployIfNotExists effect, and exempting the whole initiative suppressed that remediation action, not just the audit check — so DINE stopped deploying the diagnostic settings. The fix is to set policyDefinitionReferenceIds to only the audit/deny definition you meant to waive, leaving the DINE control enforced. Exemptions suppress all effects of whatever they cover.

5. What does expiresOn do, and what happens the moment it passes? It’s a UTC timestamp after which the exemption stops applying. On the next evaluation cycle after it passes, the scope is judged against the assignment again: if still non-compliant it flips to Non-compliant, and a Deny effect blocks new writes again. The exemption object isn’t deleted — it remains as an expired exemption to clean up — but its effect ends. There’s no native “expiring soon” alert; you must build one.

6. How do you exempt only one control inside a 40-control initiative, not the whole thing? Set the exemption’s policyDefinitionReferenceIds to the specific reference ID(s) of the definition(s) to waive — pulled exactly from the initiative definition’s policyDefinitions array. Omitting the list waives the entire initiative for that scope (a common, dangerous mistake). A typo’d reference ID silently matches nothing, leaving the control enforced.

7. Who can create a policy exemption, and why does that matter for security? Anyone with Microsoft.Authorization/policyExemptions/write at the scope — the built-in Resource Policy Contributor role, or Owner. It matters because an exemption can suppress a security control, so if application teams hold that permission at their own scope they can self-exempt the controls meant to govern them. Reserve exemption-write to a governance group and route requests through review.

8. Does creating an exemption undo a Modify or DINE action that already ran? No. Exemptions are forward-looking suppression — they stop future enforcement at the scope but do not remove a tag a Modify policy already added or delete a diagnostic setting a DINE policy already deployed. To undo an already-applied action you change the resource yourself; the exemption only stops the policy from re-applying it.

9. Why is an exemption better than lowering the policy to Audit or editing allowedValues for a one-off exception? Both change behaviour for the entire assignment/definition — every scope under it — not one resource, and leave no record on the resource that needed the exception. An exemption affects only the scope you set, keeps the policy intact for everyone else, and records category, reason, and expiry. The blast radius and auditability are completely different.

10. How do you find every exemption in your estate and which are about to expire? Query Azure Resource Graph for type == "microsoft.authorization/policyexemptions", projecting exemptionCategory, expiresOn, and the assignment, ordered by expiresOn. Run it on a schedule (Automation runbook, scheduled Action, or Logic App) for the “what’s waived, why, and what lapses soon” report, since Azure provides no built-in expiry alert.

11. In the dashboard, what does the Exempt state tell you versus Non-compliant or “not evaluated”? Exempt means the resource is governed but has a documented, tracked waiver (category and, ideally, expiry) — a known, accountable exception. Non-compliant means governed and currently failing with no waiver. A resource that’s simply not evaluated (excluded via notScopes) shows neither — it’s invisible, which is exactly why exclusions hide exceptions and exemptions surface them.

12. What’s the risk of an exemption with no expiresOn? It’s permanent — it never reviews itself, never pressures the underlying fix, and outlives the incident and the people who created it. For a Waiver that’s an un-renewed risk acceptance (nobody currently owns the risk); for any exemption it’s debt that quietly unwinds a control forever. Mandate expiresOn even though the platform makes it optional.

These map to AZ-104 (Administrator)implement and manage Azure governance: policy, initiatives, exemptions, and compliance — and especially AZ-500 (Security Engineer)manage security policies and governance, and reason about exceptions to security baselines. The compensating-control reasoning behind Mitigated, and the Defender-for-Cloud secure-score interaction, are squarely AZ-500 territory. A compact cert-mapping for revision:

Question theme Primary cert Objective area
Exemption vs exclusion, Exempt state AZ-104 Implement and manage governance
Waiver vs Mitigated, compensating controls AZ-500 Manage security policies / exceptions
Scope rules, assignmentScopeValidation AZ-104 Policy scope and assignment
policyDefinitionReferenceIds on initiatives AZ-104 Initiatives and compliance
RBAC for exemption-write, self-exemption risk AZ-500 Least-privilege governance
Secure-score interaction AZ-500 Defender for Cloud posture

Quick check

  1. Your teammate added a resource group to a policy assignment’s notScopes to “exempt” it. Why is that not the same as a policy exemption, and what’s the downside?
  2. A control in a 38-definition initiative needs waiving for one app, but after you create the exemption the whole baseline shows Exempt for that scope. What did you forget?
  3. You set up a “temporary” exemption six weeks ago and today a deploy started failing again with a Deny. What most likely happened, and what are your two options?
  4. When should you choose Mitigated over Waiver, and what must you record to keep Mitigated honest?
  5. True or false: an exemption created at a management group can waive a policy that’s assigned at a child subscription.

Answers

  1. notScopes is an exclusion — it removes the RG from the assignment so it’s never evaluated and reports compliant-by-omission, with no category, reason, or expiry. A policy exemption keeps the RG in scope but marks it Exempt (tracked, categorised, expiring). The downside of the exclusion: the exception is invisible — indistinguishable from genuine compliance — and it silently drops all the assignment’s controls for that RG, including ones unrelated to the actual exception.
  2. You omitted policyDefinitionReferenceIds. On an initiative assignment, an exemption with no reference IDs waives the entire initiative for the scope. Re-create it with the specific reference ID(s) of just the control you meant to waive, pulled from the initiative definition, and verify only that control shows Exempt.
  3. The exemption’s expiresOn passed, so the policy re-armed and the Deny is blocking again. Confirm with az policy exemption list (it shows as expired). Two options: renew the exemption with fresh justification if the exception still applies, or fix the underlying resource so it’s compliant and let the exemption stay lapsed. (And build an expiring-soon alert so it’s never a surprise next time.)
  4. Choose Mitigated when the control’s intent is genuinely met by a compensating control elsewhere (the check here is a false positive), rather than when you’re simply accepting an open risk (that’s Waiver). To keep Mitigated honest, record the compensating control in the exemption’s description so an auditor can verify the claim — an unverifiable “Mitigated” is just an unaudited hole.
  5. False. An exemption can only target a scope at or below the assignment’s scope — it narrows an assignment, never widens it. A management-group-scoped exemption sits above a child subscription’s assignment, so it can’t waive it; you’d exempt at the subscription, RG, or resource under that assignment instead.

Glossary

Next steps

You can now grant a carve-out that’s narrow, honest, and on a timer — and tell it apart from the exclusions and policy edits that hide exceptions instead of tracking them. Build outward:

AzureAzure PolicyGovernanceExemptionsComplianceRBACBicepAZ-500
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading