Microsoft 365 Security as Code — Part 1: Before you write any code
The CIS Microsoft 365 Foundations Benchmark has 160 recommendations, and writing the code is the easy part. These are the decisions that have to come first: which profile you sign up to, what is in scope, what 'done' means, and who accepts the exceptions.
This is part 1 of a series on managing Microsoft 365 security as code, based on the CIS Microsoft 365 Foundations Benchmark. Each part can be read on its own. Part 1: Before you write any code (this post) · Part 2: One benchmark, four tools · Part 3: Running Microsoft365DSC on Linux

The CIS Microsoft 365 Foundations Benchmark is a long list of settings. You can read it, turn the settings into Terraform and PowerShell, and run the pipeline. I did this for a regulated financial-services client. What I learned is that the code is not the hard part. The hard part is a set of decisions that the benchmark does not make for you, and that are painful to change after the code exists. This first post is about those decisions.
TLDR: before you write any code, decide four things. Which profile you are signing up to, and who approves it. What is in scope and what is not. What "done" means for a control. And who may accept an exception, and how it is recorded.
A control that worked exactly as written
I will start with a story, because it shows why the order matters.
One control in the Conditional Access section asks that sign-in frequency is set to "every time" for the Microsoft Intune Enrollment application. In other words, whoever enrols a device must prove their identity with multi-factor authentication again, at that moment. We turned it on, after talking with the security team, and it did what it said. Then the device builds started to fail.
The cause was not a mistake in the policy. Part of the enrolment process (the Autopilot provisioning flow) ideally runs without a person in front of it. When it was forced to authenticate again, it received an error that says the multi-factor authentication has expired, and the request asked for interaction that nobody could give. It retried in a loop, and the device builds could not finish.
We changed the policy to a freshness of two hours. Multi-factor authentication is still required at enrolment, and only the "every time" part is relaxed. That means the control is no longer met as it is written. The comment in the code says so, and the decision became a recorded deviation.
I tell this story for three reasons. A benchmark control can be correct and still break your estate. A deviation is normal, and the benchmark itself says that exceptions are expected and have to be documented. And the question that matters in the end is not whether the code was good. It is who decided, who approved, and where it is written down. A later post tells the whole story. The rest of this post is about getting ready so that you have an answer to those questions.
What the benchmark is
The benchmark is published by the Center for Internet Security (CIS). I work with version 7.0.0, dated May 2026. The PDF for the commercial cloud has 628 pages, with 160 recommendations in nine sections:
| Section | Topic | Recommendations |
|---|---|---|
| 1 | Microsoft 365 admin center | 15 |
| 2 | Microsoft 365 Defender | 21 |
| 3 | Microsoft Purview | 5 |
| 4 | Microsoft Intune | 2 |
| 5 | Microsoft Entra ID | 63 |
| 6 | Exchange Online | 13 |
| 7 | SharePoint | 12 |
| 8 | Microsoft Teams | 17 |
| 9 | Microsoft Fabric | 12 |
There are two things that stand out. Entra ID is about 39 percent of the whole, and Conditional Access alone, a single sub-section, has 17 recommendations. If you only have time for one area, that is where the risk and the work are.
Each recommendation has a profile. Level 1 is the baseline that CIS describes as practical, with limited impact on how people work. Level 2 is for environments that need stronger protection and accept that some functions may be reduced. On top of that, some recommendations apply only if you have E5 licences. Of the 160 recommendations, 114 are Level 1 and 46 are Level 2, and 23 of them (14 at Level 1 and 9 at Level 2) exist only in the E5 profiles. Seventeen are marked Manual, which means the benchmark has no automated audit for them, because they are a process or a human decision.
These numbers are from my own count of the v7.0.0 PDF, using the profile and Automated or Manual label printed under each recommendation. If CIS publishes a later version, count again before you use them in a plan. Also, I do not copy benchmark text in this series. I use the control numbers and write my own explanation, because the CIS terms of use do not allow hosting or quoting the benchmark on other sites without asking. You can get the benchmark itself from CIS.
Decision 1: which profile, and who approves it
The first question is the one that people skip: which of the four profiles (Level 1 or 2, with or without E5) are you signing up to? It looks like a technical setting. It is a risk decision, and it needs an accountable owner, because it decides which controls are expected and which are accepted risk.
In my code, Level 2 is a switch: one variable that turns Level 2 on for the whole baseline, and a separate optional value for each control that can override it. That is flexible, and it is also where it goes wrong. A switch is not an approval. Someone can set the variable to true in a merge request without anybody having decided that the organisation wants Level 2. In the control register, the profile selection is explicitly marked as waiting for an owner. The same register shows a second problem: the licence switch for E5 has a different default in Terraform and in the DSC data file, so two parts of the same baseline can disagree about what the tenant has.
What I do now: the profile is a decision with a name and a date next to it, outside the code, and the code reads it. Per-control overrides are possible, but each one has a reason.
Decision 2: what is in scope
A benchmark of 160 recommendations does not mean you should automate 160 controls. In the work I describe in this series, the code covers Entra ID, Conditional Access, Exchange Online, SharePoint, Teams and the admin center. It does not cover Purview, Intune or Fabric. I have not built them, so I will not write about them as if I had. Intune is implemented in a different repo, built by another team. Purview and Fabric are on the roadmap and will be implemented later.
Say what is out of scope at the start, in writing. Otherwise your report looks like it is about the whole benchmark, and a reader will assume that anything not mentioned is fine.
Decision 3: what "done" means
This is the one that I changed my mind about most. "Implemented" can mean at least five different things:
- The setting is in the code. It does not mean it is deployed.
- The setting is in the code, but only if a switch, a licence or a list has a value.
- The policy exists, but it is in report-only mode: it logs and does not enforce.
- The setting differs from the benchmark on purpose, and a deviation describes why.
- Nothing in the code covers it. A person or another team does it, outside these repositories.
If you put all of these in one column that says "done", your report is wrong in a way that is hard to see. In the control register, each control has one status from a fixed list: declared, partial, conditional, report-only, documented deviation, manual or external, and not found. The register also does not calculate a compliance percentage, and I will not publish one. A percentage needs a complete inventory and an approved profile, and those two things come from Decisions 1 and 2.
The same care is needed for assessment tools. A Maester run gave me 771 results. That sounds like 771 controls. It is not: the results come from several test suites, only 44 of them are native CIS tests, and some tests did not run because a permission was missing. A later post goes into this.
Decision 4: who accepts an exception
Some control will break something. The benchmark says so itself, and it expects that you record what you did instead. What you need is a record per exception, with these fields: which control, what is different, the compensating control, the residual risk, an owner, an approver, an approval reference and an expiry date.
Then comes the uncomfortable part. When I generated this register from the code, the exceptions came out with the fields for owner, approver and expiry empty. The code showed the deviation, and the comment in the code explained it, but nobody had written down who accepted it. I show those fields as empty. I do not fill them with a name that sounds right, and I do not call an exception accepted because the code says so. A comment in a Terraform file is evidence that a decision was made. It is not proof that someone with the authority approved it. This is a common pattern, and I think it is more useful to name it than to hide it.
What comes next in the series
The rest of the series follows the order in which the questions appeared in the work:
- Part 2: which tool owns which control, and why the code lives in several repositories.
- Part 3: running Microsoft365DSC on a Linux pipeline.
- Part 4: deploying to a production tenant without stored secrets.
- Parts 5 and 6: Conditional Access, and Entra ID settings that have no API.
- Part 7: when the benchmark breaks your estate, and exceptions as data.
- Part 8: evidence, and why I do not publish a score.
Before you start
A short list you can use:
- Write down the profile you are signing up to, and who approves it.
- List the sections that are out of scope, and say so in your first report.
- Choose a fixed list of statuses for "implemented", and do not allow a plain "done".
- Create the exception record before you need it, with an owner and an expiry date.
- Decide where the evidence lives, so that a pass or a failure can be shown later.
Get in touch
I help teams that need Microsoft 365 security to be written down as code, reviewed like code, and provable to an auditor, especially in financial services and other regulated environments. If you are starting on the CIS benchmark and want to talk about profile, scope or exceptions, I would like to hear from you. Connect with me on LinkedIn, or reach out via ascode.nl.
This post is part of the series Microsoft 365 Security as Code, where you can find all the parts.