Suppose your next onboarding batch includes 47 employees, three departments, two contractor cohorts, and one executive who needs access yesterday.
You can click through that in the Microsoft Entra admin center, but every click becomes a tiny undocumented decision. This tutorial walks you through a safer path: install Microsoft Graph PowerShell, import users from structured input, create security groups, assign group membership, and build a Conditional Access policy in report-only mode. Conditional Access is Microsoft’s sign-in policy engine, and report-only mode lets you test the decision logic without blocking anyone yet. By the end, you will have a repeatable script pattern you can run again without creating duplicate users, duplicate groups, or a very public lockout incident.
Prerequisites
If you want to follow along hands-on, you will need:
-
A Microsoft Entra tenant where you can create test users, groups, and Conditional Access policies; Pluralsight’s SC-300 identity and access administrator training is a structured refresher before you test identity and access controls in a tenant.
-
PowerShell 7 for the cleanest cross-platform experience; Windows PowerShell 5.1 works with extra prerequisites.
-
The Microsoft Graph PowerShell SDK because the examples use Graph cmdlets instead of retired AzureAD or MSOnline commands.
-
A role that can consent to Graph permissions and manage users, groups, and Conditional Access; do this in a test tenant first.
-
Microsoft Entra ID P1 or a license that includes Conditional Access if you want to create and test policies.
Install the Microsoft Graph PowerShell SDK from an elevated or user-scoped PowerShell session.
Install-Module Microsoft.Graph -Scope CurrentUser -Repository PSGallery -Force
The SDK installs cmdlets for Microsoft Graph, the API layer behind Microsoft Entra ID and many Microsoft 365 workloads. That matters because your automation should not depend on a portal workflow that changes position every time somebody at Microsoft moves a blade.
Verify the module is available before you start changing directory objects.
Get-InstalledModule Microsoft.Graph
That output is your first safety gate. If PowerShell cannot load the SDK, nothing later in the tutorial should run.
Connect With the Right Graph Permissions
Your script needs enough Microsoft Graph permissions to do the job, but not every permission under the sun. The common shortcut is to consent to Directory.ReadWrite.All and move on. That works, in the same way using a domain admin account for a printer script “works.”
Use delegated permissions while you are building the script interactively.
$scopes = @(
"User.ReadWrite.All",
"Group.ReadWrite.All",
"Policy.Read.All",
"Policy.ReadWrite.ConditionalAccess"
)
Connect-MgGraph -Scopes $scopes
The Connect-MgGraph cmdlet requests an access token for the scopes you specify. User.ReadWrite.All lets you create and update users, Group.ReadWrite.All covers group creation and membership, and Policy.ReadWrite.ConditionalAccess is the permission that allows Conditional Access policy changes. Microsoft maintains the underlying Graph permissions reference, and you should check that reference when a script grows beyond this tutorial.
| Automation task | Minimum useful permission | Why it matters |
|---|---|---|
| Create users | User.ReadWrite.All |
Required by New-MgUser for user writes |
| Create groups | Group.ReadWrite.All |
Required for group creation and membership writes |
| Read policies | Policy.Read.All |
Lets you export current Conditional Access policy state |
| Change policies | Policy.ReadWrite.ConditionalAccess |
Required for Conditional Access create and update operations |
Do not treat this table as a permanent permission design. It is a build-and-test scope list for an interactive tutorial. When the workflow becomes a runbook, revisit each permission and decide whether the automation identity needs it every time or only during setup.
Warning: Do not test Conditional Access automation with the only account that can fix Conditional Access automation. Use at least one emergency access account excluded from the policy scope.
Once authentication works, capture the tenant context for your log output.
Get-MgContext | Select-Object TenantId, Account, Scopes
The point is not ceremony. When a script can change identity controls, the log should prove who ran it, against which tenant, and with which permission set.
Prepare User Input That Scripts Can Trust
Start with a CSV file that looks boring on purpose. Your automation should not guess department names, invent mail nicknames, or silently accept a blank user principal name. Bad identity input becomes bad access control later.
Create a file named new-users.csv.
DisplayName,UserPrincipalName,MailNickname,Department,JobTitle,UsageLocation,TempPassword Ada Russell,[email protected],adarussell,Finance,Analyst,US,ChangeMe!2026 Noah Patel,[email protected],noahpatel,IT,Support Engineer,US,ChangeMe!2026 Mia Chen,[email protected],miachen,Sales,Account Executive,US,ChangeMe!2026
Validate the File Shape
Before you create anything, validate the input. This step feels tedious until it saves you from creating mchen@contoso because somebody’s export truncated a column.
$users = Import-Csv .\new-users.csv
$requiredColumns = @(
"DisplayName",
"UserPrincipalName",
"MailNickname",
"Department",
"UsageLocation",
"TempPassword"
)
$missingColumns = $requiredColumns | Where-Object {
$_ -notin $users[0].PSObject.Properties.Name
}
if ($missingColumns) {
throw "Missing required CSV columns: $($missingColumns -join ', ')"
}
$duplicateUpns = $users |
Group-Object UserPrincipalName |
Where-Object Count -gt 1
if ($duplicateUpns) {
throw "Duplicate UPNs in input: $($duplicateUpns.Name -join ', ')"
}
This validation checks the file shape and catches duplicate user principal names before Microsoft Graph sees the request. You can add your own rules here, such as approved departments, allowed usage locations, or a naming policy for contractor accounts.
PowerShell’s Import-Csv cmdlet turns each row into an object, which is convenient and dangerous in equal measure. Convenient because the rest of the script can read $user.Department. Dangerous because an empty or malformed column still becomes input unless you reject it.
| Input check | Failure it catches | Action to take |
|---|---|---|
| Required columns | Export template changed or dropped a field | Stop before Graph calls |
| Duplicate UPNs | Two rows try to manage the same identity | Fix the source CSV |
| Approved departments | Access rules depend on department values | Reject unknown values |
| Usage location | Licensing and service availability depend on location | Require a known country code |
Keep Business Rules Close to the Import
Put your business rules next to the import instead of burying them in the user creation function. The creation function should know how to write a user. The import gate should decide whether the row deserves to become a user in the first place.
That separation matters during cleanup. If a bad department value gets through, you want one obvious place to add the rule and rerun the script. You do not want to inspect every later function and wonder which one quietly accepted the bad value.
The workflow below shows why that validation belongs at the front of the process, as you can see below.
Identity workflow
Create or Update Users Idempotently
Idempotent automation can run twice and produce the same intended state. For user provisioning, that means your script should look for an existing user first, then create or update only what needs to change. If a scheduled job retries after a transient Graph error, a rerun should cleanly converge instead of creating a second account with a slightly different name.
Microsoft documents that New-MgUser creates a user and requires properties such as accountEnabled, displayName, mailNickname, passwordProfile, and userPrincipalName. Wrap that behavior in a small function so the rest of your script can stay readable.
function Ensure-EntraUser {
param(
[Parameter(Mandatory)]
[pscustomobject]$InputUser
)
$existingUser = Get-MgUser `
-Filter "userPrincipalName eq '$($InputUser.UserPrincipalName)'" `
-ConsistencyLevel eventual `
-CountVariable userCount `
-ErrorAction Stop
$passwordProfile = @{
Password = $InputUser.TempPassword
ForceChangePasswordNextSignIn = $true
}
if (-not $existingUser) {
New-MgUser `
-DisplayName $InputUser.DisplayName `
-UserPrincipalName $InputUser.UserPrincipalName `
-MailNickname $InputUser.MailNickname `
-AccountEnabled `
-PasswordProfile $passwordProfile `
-Department $InputUser.Department `
-JobTitle $InputUser.JobTitle `
-UsageLocation $InputUser.UsageLocation
}
else {
Update-MgUser `
-UserId $existingUser.Id `
-Department $InputUser.Department `
-JobTitle $InputUser.JobTitle `
-UsageLocation $InputUser.UsageLocation
Get-MgUser -UserId $existingUser.Id
}
}
Run the function against each CSV row.
$createdOrUpdatedUsers = foreach ($user in $users) {
Ensure-EntraUser -InputUser $user
}
$createdOrUpdatedUsers |
Select-Object DisplayName, UserPrincipalName, Department, Id
The function checks for an existing user by user principal name, creates missing accounts, and updates selected attributes on existing accounts. It deliberately avoids resetting passwords on existing users. Password resets are a separate workflow, and mixing them into a provisioning script is how “minor cleanup” becomes a help desk spike.
Decide What the Function Owns
Keep the function narrow enough that you can reason about a rerun. It owns identity creation and a few profile attributes. It does not own licensing, password resets for existing users, role assignments, or mailbox settings.
| Decision point | Safer default | Why |
|---|---|---|
| Existing user found | Update profile fields only | Avoid surprise password resets |
| Missing optional field | Leave existing value alone | Prevent blank CSV cells from erasing data |
| Graph lookup returns multiple objects | Stop the run | Ambiguous identity matching is not automation |
| Create succeeds but later steps fail | Rerun after fixing the later step | Idempotent lookup prevents duplicate users |
This is where a lot of identity scripts go wrong. They start as provisioning scripts and slowly absorb every adjacent task until a harmless CSV import can reset passwords, change licenses, and move group membership in one run. That might feel efficient. It is also hard to review, hard to roll back, and too powerful for a routine onboarding batch.
For very large HR feeds, do not force every identity change through a row-by-row create script. Microsoft Entra also supports API-driven inbound provisioning with a /bulkUpload endpoint that accepts SCIM-style bulk requests and processes them asynchronously through the provisioning service. That is a better fit when your source of truth is an HR system, SQL table, or integration pipeline rather than an admin-maintained CSV.
Create Security Groups for Policy Assignment
Conditional Access policies can target users directly, but direct user targeting does not scale cleanly. Use groups as the assignment layer so the policy scope is something you can inspect, export, and reason about.
The group becomes the contract between identity data and sign-in policy. Your CSV import decides who should exist, the group decides who is in scope, and Conditional Access decides what controls apply during sign-in. When those concerns blur together, troubleshooting turns into guesswork.
Create a reusable function that ensures a security group exists.
function Ensure-EntraSecurityGroup {
param(
[Parameter(Mandatory)]
[string]$DisplayName,
[Parameter(Mandatory)]
[string]$MailNickname
)
$group = Get-MgGroup `
-Filter "displayName eq '$DisplayName'" `
-ConsistencyLevel eventual `
-CountVariable groupCount
if ($group) {
return $group
}
New-MgGroup `
-DisplayName $DisplayName `
-MailEnabled:$false `
-MailNickname $MailNickname `
-SecurityEnabled
}
$mfaGroup = Ensure-EntraSecurityGroup `
-DisplayName "CA-Require-MFA-All-Employees" `
-MailNickname "ca-require-mfa-all-employees"
The New-MgGroup cmdlet creates group objects and supports security-enabled groups. A group name like CA-Require-MFA-All-Employees may look verbose, but that verbosity pays rent when you are staring at a sign-in log at 1:17 a.m.
Dynamic groups can reduce manual membership work, but treat them as a policy dependency, not decoration. Microsoft says dynamic membership groups require Microsoft Entra ID P1 licensing for each unique user who is a member of one or more dynamic groups, and a tenant can have up to 15,000 dynamic membership groups. More importantly, if a user-controlled or loosely governed attribute drives a security-sensitive group, your access model is only as trustworthy as that attribute.
Use assigned groups first while you prove the workflow. Add dynamic membership only after you know which attributes are authoritative.
Add Users to Groups Without Duplicate Errors
Adding a group member is easy. Adding a group member safely means checking membership first, then writing only when the relationship is missing.
function Ensure-EntraGroupMember {
param(
[Parameter(Mandatory)]
[string]$GroupId,
[Parameter(Mandatory)]
[string]$UserId
)
$members = Get-MgGroupMember -GroupId $GroupId -All
$alreadyMember = $members.Id -contains $UserId
if ($alreadyMember) {
return [pscustomobject]@{
GroupId = $GroupId
UserId = $UserId
Action = "AlreadyMember"
}
}
$body = @{
"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$UserId"
}
New-MgGroupMemberByRef -GroupId $GroupId -BodyParameter $body
[pscustomobject]@{
GroupId = $GroupId
UserId = $UserId
Action = "Added"
}
}
Use the function to place each imported user into the Conditional Access assignment group.
$membershipResults = foreach ($user in $createdOrUpdatedUsers) {
Ensure-EntraGroupMember -GroupId $mfaGroup.Id -UserId $user.Id
}
$membershipResults | Format-Table
The New-MgGroupMemberByRef operation adds members by reference. Checking first keeps your script output clean and makes reruns less dramatic. Drama belongs in incident reviews, not scheduled runbooks.
Validate the final membership count before you build the policy around the group.
$finalMembers = Get-MgGroupMember -GroupId $mfaGroup.Id -All
[pscustomobject]@{
GroupDisplayName = $mfaGroup.DisplayName
GroupId = $mfaGroup.Id
MemberCount = $finalMembers.Count
}
That count is not just a nice-to-have. It is the blast radius of the Conditional Access policy you are about to create.
Create a Conditional Access Policy in Report-Only Mode
Conditional Access is Microsoft Entra’s policy engine for deciding whether a sign-in should be allowed, blocked, or allowed only after controls such as multifactor authentication. The safe way to automate it is to create the policy disabled or in report-only mode first, validate the scope, and then enable enforcement after you have evidence.
Build a group-based policy that requires multifactor authentication for the imported users.
$emergencyAccessUserId = "<emergency-access-object-id>"
if ($emergencyAccessUserId -eq "00000000-0000-0000-0000-000000000000") {
throw "Replace the emergency access account placeholder before creating the policy."
}
$conditions = @{
users = @{
includeGroups = @($mfaGroup.Id)
excludeUsers = @($emergencyAccessUserId)
}
applications = @{
includeApplications = @("All")
}
clientAppTypes = @(
"browser",
"mobileAppsAndDesktopClients"
)
}
$grantControls = @{
operator = "OR"
builtInControls = @("mfa")
}
$policy = New-MgIdentityConditionalAccessPolicy `
-DisplayName "Require MFA for imported employees" `
-State "enabledForReportingButNotEnforced" `
-Conditions $conditions `
-GrantControls $grantControls
Understand the Payload Before You Run It
The policy payload is short, but several fields carry real operational weight. Read them as a blast-radius statement, not just as PowerShell syntax.
| Field | Current value | Effect |
|---|---|---|
includeGroups |
$mfaGroup.Id |
Limits the policy to members of the assignment group |
excludeUsers |
Emergency access object ID placeholder | Keeps a recovery account outside the policy |
includeApplications |
All |
Applies to all cloud apps for included users |
clientAppTypes |
Browser and mobile or desktop clients | Covers the modern clients most users touch daily |
operator |
OR |
Requires one listed grant control to pass |
builtInControls |
mfa |
Requires multifactor authentication |
State |
enabledForReportingButNotEnforced |
Evaluates sign-ins without enforcing the result |
The includeApplications = @("All") line deserves special attention. For the included group, this policy evaluates access to every cloud app, not just one test app. That scope is useful for a baseline MFA policy, but it is not a quiet change. If you only want to test a narrow application, replace All with the application ID before you create the policy.
The clientAppTypes values also shape what the policy sees. This example targets browsers and modern mobile or desktop clients because that is where most interactive user sign-ins happen. If your tenant still has legacy authentication exposure, handle that deliberately with a separate policy and a separate test plan.
Replace the all-zero placeholder in excludeUsers with the object ID of your emergency access account. Microsoft recommends planning Conditional Access user exclusions, and this is the place to make that recommendation executable. If the break-glass exclusion is not in the payload, stop the script.
Reality Check: A Conditional Access policy is not a note to self. Once enabled, it becomes a sign-in gate for real people doing real work.
The New-MgIdentityConditionalAccessPolicy cmdlet creates the policy object. The important detail is the enabledForReportingButNotEnforced state, which Microsoft describes as report-only behavior for testing many policies before enforcement. In other words, Microsoft Entra records what the policy would have done, but it does not interrupt the user yet.
The safety gates below belong in your deployment process, as you can see below.
Policy safety gates
Validate Scope Before Enabling Enforcement
Your next job is to prove the policy targets the group you think it targets. Do not enable enforcement because the create command returned an object. “It exists” and “it is correct” are different sentences.
Export the policy, its included group, and the member count to JSON.
$policyAudit = [pscustomobject]@{
PolicyName = $policy.DisplayName
PolicyId = $policy.Id
PolicyState = $policy.State
IncludedGroupName = $mfaGroup.DisplayName
IncludedGroupId = $mfaGroup.Id
MemberCount = $finalMembers.Count
ExportedAt = (Get-Date).ToUniversalTime().ToString("o")
}
$policyAudit |
ConvertTo-Json -Depth 5 |
Out-File .\conditional-access-audit.json -Encoding utf8
Then retrieve the policy from Microsoft Graph and compare the state.
Get-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId $policy.Id |
Select-Object DisplayName, State, Id
The Get-MgIdentityConditionalAccessPolicy cmdlet gives you the policy state that Microsoft Graph has stored, not the object you remember creating 30 seconds ago. That difference matters in runbooks where a partial failure can leave the tenant in a half-configured state.
Before you switch to enforcement, check these items:
-
The included group ID matches the group your script created.
-
The included group has the expected member count.
-
The emergency access account is excluded by object ID, not display name.
-
The policy is still in report-only mode.
-
The JSON audit file is stored somewhere your change process can retain.
If any item fails, fix the input or group membership first. Conditional Access should be the last object you enable, not the first object you hope is right.
Treat the JSON file as change evidence, not a souvenir. Store it where your normal change process can find it later, whether that is a ticket, a repository, or a storage account with retention. If someone asks why a user was challenged for MFA after the policy goes live, the audit file should answer the first round of questions before anyone opens the portal.
Move From Interactive Script to Runbook
An interactive script is fine while you are proving the workflow. Production identity lifecycle work belongs in a controlled automation host such as Azure Automation, a CI/CD runner with approved secrets handling, or another managed execution platform.
For unattended runs, avoid stored user passwords. Microsoft Graph PowerShell supports application and certificate-based authentication patterns, and Microsoft also documents granting inbound provisioning access to a service principal or managed identity. Managed identity is the cleaner option when your automation runs in Azure because you do not have a secret to rotate or accidentally paste into a ticket.
Separate Human Approval From Script Execution
The script can prepare the policy, export the audit file, and report the intended scope. A human change process should decide when that policy moves from report-only to enforced. That does not make the workflow less automated. It makes the dangerous part explicit.
Keep the runbook split into stages:
-
Import and validate source data.
-
Create or update users.
-
Create or update groups.
-
Reconcile group membership.
-
Create or update Conditional Access in report-only mode.
-
Export audit evidence.
-
Enable enforcement only after approval.
That sequence is intentionally conservative. Users and groups are inputs to the policy; the policy should not get ahead of them.
| Runbook stage | Evidence to retain | Common failure |
|---|---|---|
| Input validation | CSV validation output | Missing or duplicate identities |
| User changes | Created or updated user list | Partial run after Graph throttling |
| Group reconciliation | Member count and action list | Wrong users in the assignment group |
| Policy creation | Report-only policy export | Emergency access account not excluded |
| Enforcement approval | Change record and final state | Policy enabled before scope review |
You can also extend this pattern into a joiner-mover-leaver workflow. Joiners get created and assigned to baseline groups. Movers have department and job attributes updated before group membership changes. Leavers get disabled, sessions revoked, groups stripped, and licenses removed. The PowerShell changes are different, but the rule is the same: prove the directory state before you change the access gate.
Make the Automation Boring
Good Microsoft Entra ID automation should feel almost dull. It reads structured input, validates it, changes only what needs changing, logs what it did, and refuses to enable Conditional Access until the scope is proven.
The important pieces are simple:
-
Use Microsoft Graph PowerShell for users, groups, and Conditional Access policy work.
-
Ask for specific Graph permissions instead of consenting to broad rights by habit.
-
Treat group membership as the bridge between identity data and policy assignment.
-
Create Conditional Access policies in report-only mode first.
-
Export audit evidence before you enforce anything.
Microsoft’s guidance for building resilience into identity and access management points in the same direction: identity systems need predictable processes, recovery paths, and evidence. Your PowerShell script should support those habits instead of becoming a second, undocumented control plane.
Key Insight: The script is not the control. The control is the repeatable process that proves who gets access, why they get it, and what happens when the input changes.
Once that process is in place, the portal becomes a place to inspect results, not the place where identity decisions quietly happen one click at a time.