Creating Active Directory Users with PowerShell: Safe CSV Import Pattern - WordPad

Creating Active Directory Users with PowerShell: Safe CSV Import Pattern

Last reviewed: June 2026

Scope

Creating users from a CSV is a five-line script. Doing it safely, idempotently, and reversibly in production is a tool. This article builds that tool: a two-stage onboarding pipeline that validates the import before it touches the directory, previews exactly what it will do, creates accounts idempotently with secure passwords, captures rollback evidence, and ships with Pester tests. The same engineering patterns from Production-Ready AD Automation apply here — advanced functions, ShouldProcess, fail-closed error handling, structured logging — pointed at onboarding instead of cleanup.

The risk is the input file, not the cmdlet

New-ADUser is not dangerous. Trusting a CSV is. The batches that cause incidents are mundane: a trailing blank row that creates a malformed account, a Department column that routes new starters into the wrong OU so they inherit the wrong GPOs, a duplicate SamAccountName that fails halfway and leaves the batch half-applied, or a UPN suffix that doesn't match a verified domain so Microsoft Entra ID sync rejects the accounts the next morning. Every one of those is invisible in a happy-path run and obvious in a validation pass. So the tool is built around a hard rule: validate everything, change nothing — then create only what validation approved.

That split — a read-only Test-AdUserImport and a state-changing New-AdUserFromImport — is what makes the onboarding reviewable. A change reviewer runs the validation stage, reads the plan, and only then pipes the approved rows into creation.

Stage 1: validate the import (read-only)

function Test-AdUserImport {
    <#
    .SYNOPSIS
        Validates an onboarding CSV against AD and returns a per-row plan. Makes no changes.
    .EXAMPLE
        Test-AdUserImport -CsvPath .\new-hires.csv -DefaultUpnSuffix contoso.com | Format-Table
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path $_ -PathType Leaf })]
        [string]$CsvPath,

        [Parameter(Mandatory)]
        [string]$DefaultUpnSuffix,

        [string]$Server
    )

    $rows = Import-Csv -Path $CsvPath
    if (-not $rows) { throw "CSV '$CsvPath' contained no rows." }

    $required = 'GivenName', 'Surname', 'SamAccountName', 'TargetOu'
    $missing = $required | Where-Object { $_ -notin $rows[0].PSObject.Properties.Name }
    if ($missing) { throw "CSV is missing required column(s): $($missing -join ', ')" }

    $common = @{}
    if ($PSBoundParameters.ContainsKey('Server')) { $common.Server = $Server }

    $seen = @{}
    foreach ($row in $rows) {
        $sam = "$($row.SamAccountName)".Trim()
        $upn = if ($row.UserPrincipalName) { "$($row.UserPrincipalName)".Trim() } else { "$sam@$DefaultUpnSuffix" }
        $issues = [System.Collections.Generic.List[string]]::new()

        if ([string]::IsNullOrWhiteSpace($sam)) { $issues.Add('Blank SamAccountName') }
        if ($sam.Length -gt 20) { $issues.Add('SamAccountName exceeds 20 characters') }
        if ($seen.ContainsKey($sam)) { $issues.Add('Duplicate SamAccountName within the file') } else { $seen[$sam] = $true }
        if (-not (Get-ADOrganizationalUnit -Identity $row.TargetOu @common -ErrorAction SilentlyContinue)) {
            $issues.Add("Target OU not found: $($row.TargetOu)")
        }

        $exists = [bool](Get-ADUser -Filter "SamAccountName -eq '$sam'" @common -ErrorAction SilentlyContinue)

        $plan =
            if ($exists) { 'Skip-Exists' }
            elseif ($issues.Count) { 'Blocked' }
            else { 'Create' }

        [pscustomobject]@{
            SamAccountName    = $sam
            UserPrincipalName = $upn
            GivenName         = $row.GivenName
            Surname           = $row.Surname
            TargetOu          = $row.TargetOu
            Groups            = if ($row.Groups) { ($row.Groups -split ';').Trim() } else { @() }
            Plan              = $plan
            Issues            = $issues -join '; '
        }
    }
}

The function never calls a change cmdlet. It checks the CSV schema, confirms each target OU exists, detects duplicates both within the file and against the directory, and assigns every row a Plan: Create, Skip-Exists (the account is already there — this is what makes re-running safe), or Blocked (something is wrong, with the reason in Issues). The output is a table a human can read and approve before anything happens.

Stage 2: create the approved rows (state-changing, gated)

This stage consumes the plan from the pipeline and acts only on Create rows. It generates a secure password, creates the account idempotently, adds group memberships, logs every action, and writes a rollback file of exactly what it created.

function New-RandomPassword {
    [OutputType([securestring])]
    param([ValidateRange(12, 128)][int]$Length = 20)

    $bytes = [byte[]]::new($Length)
    [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
    $alphabet = [char[]](48..57 + 65..90 + 97..122 + @(33, 35, 36, 37, 38, 42, 64))
    $plain = -join ($bytes | ForEach-Object { $alphabet[$_ % $alphabet.Length] })
    ConvertTo-SecureString -String $plain -AsPlainText -Force
}

function New-AdUserFromImport {
    <#
    .SYNOPSIS
        Creates approved users from a validated import plan, with rollback and logging.
    .EXAMPLE
        Test-AdUserImport -CsvPath .\new-hires.csv -DefaultUpnSuffix contoso.com |
            New-AdUserFromImport -RollbackPath .\rb.csv -LogPath .\log.csv -WhatIf
    #>
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [pscustomobject]$ImportRow,

        [Parameter(Mandatory)]
        [string]$RollbackPath,

        [Parameter(Mandatory)]
        [string]$LogPath,

        [string]$Server
    )

    begin {
        $common = @{}
        if ($PSBoundParameters.ContainsKey('Server')) { $common.Server = $Server }
        $created = [System.Collections.Generic.List[object]]::new()
    }

    process {
        if ($ImportRow.Plan -ne 'Create') {
            Write-OperationLog -Action 'Create' -Target $ImportRow.SamAccountName -Result 'Skipped' -Detail $ImportRow.Plan -LogPath $LogPath
            return
        }

        try {
            if ($PSCmdlet.ShouldProcess($ImportRow.SamAccountName, 'Create Active Directory user')) {
                $attrs = @{
                    Name                  = "$($ImportRow.GivenName) $($ImportRow.Surname)"
                    GivenName             = $ImportRow.GivenName
                    Surname               = $ImportRow.Surname
                    SamAccountName        = $ImportRow.SamAccountName
                    UserPrincipalName     = $ImportRow.UserPrincipalName
                    Path                  = $ImportRow.TargetOu
                    AccountPassword       = New-RandomPassword
                    Enabled               = $true
                    ChangePasswordAtLogon = $true
                }
                New-ADUser @attrs @common -ErrorAction Stop

                foreach ($group in $ImportRow.Groups) {
                    Add-ADGroupMember -Identity $group -Members $ImportRow.SamAccountName @common -ErrorAction Stop
                }

                $created.Add([pscustomobject]@{
                        SamAccountName    = $ImportRow.SamAccountName
                        UserPrincipalName = $ImportRow.UserPrincipalName
                        TargetOu          = $ImportRow.TargetOu
                        CreatedAt         = (Get-Date).ToString('o')
                    })
                Write-OperationLog -Action 'Create' -Target $ImportRow.SamAccountName -Result 'Success' -LogPath $LogPath
            }
            else {
                Write-OperationLog -Action 'Create' -Target $ImportRow.SamAccountName -Result 'WhatIf' -LogPath $LogPath
            }
        }
        catch {
            Write-OperationLog -Action 'Create' -Target $ImportRow.SamAccountName -Result 'Failure' -Detail $_.Exception.Message -LogPath $LogPath
            Write-Error "Failed to create $($ImportRow.SamAccountName): $($_.Exception.Message)"
        }
    }

    end {
        if ($created.Count) {
            $created | Export-Csv -Path $RollbackPath -NoTypeInformation -Encoding UTF8
        }
    }
}

Write-OperationLog is the same structured-logging helper from the AD automation article — one object-and-CSV record per action. The idempotency is the quiet but important property: because Stage 1 marks already-existing accounts as Skip-Exists and Stage 2 acts only on Create, you can run the same batch twice — after a partial failure, say — and it will create only what is missing, never duplicate or error on what already exists. The rollback file lists exactly the accounts this run created, so reversing a bad batch is a precise operation, not a guess.

Passwords: never in the CSV, never in the log

This is the security decision that matters most, and the one most onboarding scripts get wrong. The password is generated as a SecureString at the moment of creation, with ChangePasswordAtLogon set so the temporary value is replaced on first sign-in. It is never read from the CSV and never written to the log or the rollback file. Working under GDPR makes the reasoning concrete: a CSV of usernames and temporary passwords sitting in a shared folder is exactly the artifact that turns an onboarding task into a data-protection incident. If the temporary password must be delivered to a human, do it through a channel your security owner has approved, treat any password-bearing output as sensitive data with a short retention and a named owner, and never email the file. (Production note: confirm the generated password satisfies your domain's complexity policy, or use a generator that guarantees the required character categories.)

A CSV schema that survives contact with HR

GivenName,Surname,SamAccountName,UserPrincipalName,TargetOu,Groups
Alex,Example,aexample,alex.example@contoso.com,"OU=Staff,DC=contoso,DC=com","GG-IT-Users;GG-M365-E3"

UserPrincipalName is optional — Stage 1 builds one from the SamAccountName and the default suffix if it is blank — and Groups is a semicolon-separated list. Everything else is required, and Stage 1 rejects the file if a required column is missing.

Prove it with Pester

Because validation and creation are separate and the AD cmdlets are mockable, the behaviour that matters is testable without a directory.

BeforeAll {
    . $PSScriptRoot\AdOnboarding.ps1
}

Describe 'Test-AdUserImport' {
    BeforeAll {
        Mock Get-ADOrganizationalUnit { $true }                 # OU exists
        Mock Get-ADUser { $null }                               # nobody exists yet
        $csv = Join-Path $TestDrive 'in.csv'
        @'
GivenName,Surname,SamAccountName,TargetOu
Alex,Example,aexample,OU=Staff,DC=contoso,DC=com
Sam,Sample,aexample,OU=Staff,DC=contoso,DC=com
'@ | Set-Content $csv
    }

    It 'plans a clean new row as Create' {
        (Test-AdUserImport -CsvPath $csv -DefaultUpnSuffix contoso.com)[0].Plan | Should -Be 'Create'
    }
    It 'blocks an in-file duplicate SamAccountName' {
        (Test-AdUserImport -CsvPath $csv -DefaultUpnSuffix contoso.com)[1].Plan | Should -Be 'Blocked'
    }
    It 'marks an account that already exists as Skip-Exists (idempotent)' {
        Mock Get-ADUser { [pscustomobject]@{ SamAccountName = 'aexample' } }
        (Test-AdUserImport -CsvPath $csv -DefaultUpnSuffix contoso.com)[0].Plan | Should -Be 'Skip-Exists'
    }
}

Describe 'New-AdUserFromImport' {
    BeforeAll {
        Mock New-ADUser {}
        Mock Add-ADGroupMember {}
        Mock Export-Csv {}
    }
    BeforeEach {
        $script:create = [pscustomobject]@{ SamAccountName = 'aexample'; UserPrincipalName = 'aexample@contoso.com'; GivenName = 'Alex'; Surname = 'Example'; TargetOu = 'OU=Staff,DC=contoso,DC=com'; Groups = @(); Plan = 'Create' }
    }

    It 'creates nothing under -WhatIf' {
        $script:create | New-AdUserFromImport -RollbackPath 'TestDrive:\rb.csv' -LogPath 'TestDrive:\log.csv' -WhatIf
        Should -Invoke New-ADUser -Times 0
    }
    It 'creates a Create-planned row when confirmed' {
        $script:create | New-AdUserFromImport -RollbackPath 'TestDrive:\rb.csv' -LogPath 'TestDrive:\log.csv' -Confirm:$false
        Should -Invoke New-ADUser -Times 1
    }
    It 'skips a non-Create row without calling New-ADUser' {
        $script:create.Plan = 'Blocked'
        $script:create | New-AdUserFromImport -RollbackPath 'TestDrive:\rb.csv' -LogPath 'TestDrive:\log.csv' -Confirm:$false
        Should -Invoke New-ADUser -Times 0
    }
}

The duplicate, idempotency, and -WhatIf tests encode the three properties that keep onboarding safe: the same file can't create the same account twice, an account that already exists is left alone, and a preview run changes nothing.

Production checklist

  • Run Stage 1 and have a human approve the plan before Stage 2.
  • Confirm the CSV source is trusted and the columns are validated.
  • Keep creation idempotent so a re-run after a partial failure is safe.
  • Generate passwords as SecureString; never write them to the CSV or log.
  • Capture a rollback file of exactly what was created.
  • Log every action — success, skip, -WhatIf, and failure — as structured data.
  • Run the Pester tests in CI on every change to the module.
  • Execute under a least-privilege account with rights scoped to the target OUs.

Final recommendation

Treat CSV onboarding as a controlled, idempotent, two-stage process, not a single Import-Csv | New-ADUser pipeline. Validate first and change nothing; create only what validation approved; keep passwords out of files; and capture rollback evidence as you go. If the tool can't produce a readable plan, run twice without harm, and pass a test proving -WhatIf makes no change, it isn't ready for an onboarding batch — and onboarding batches are exactly when everyone is in a hurry.

Related infrastructure guides

For Help, press F1 1635 words Ln 1, Col 1