Active Directory onboarding and offboarding with PowerShell: the script I use at work
How I automate Active Directory joiners and leavers with PowerShell: usernames, temporary passwords, groups, home folders and an audit log.
Creating a user in Active Directory by hand is ten clicks per account, and every one of them is a chance to get it wrong: the wrong OU, a forgotten group, a home folder with no permissions. Leavers are worse, because when one is forgotten nobody complains, and the account of someone who’s gone stays active for months.
At work (public sector) we’ve solved this with a few PowerShell scripts and a simple window on top. Here’s how they’re built, so you can make your own.
Everything here is generic: no server names, nothing about the organisation. Domains, paths and groups are examples.
1. What onboarding does
The technician fills in first name, surname and department. The rest is optional: email, phone, description and expiry date (for interns or fixed-term contracts). On “Create user”, the script:
- Works out the username.
- Generates a temporary password the user must change at first sign-in.
- Creates the account in the department’s OU and adds it to its groups.
- Creates the home folder with the right permissions.
- Emails IT and, if requested, sends the user a welcome message.
- Writes it to the audit log.
- Asks GLPI to sync users from the directory right away.
The window and the command line call the same script. The window just collects the data:
.\AD-Alta.ps1 -Nombre "Lucía" -Apellidos "de la Peña Ruiz" -Departamento "Administracion"
2. The username: accents, ñ and duplicates
This is where half the problems live. Our rule is first initial plus first surname, lowercase, no odd characters. Three things to think about:
- Accents and ñ: “Peña” has to become
pena, notpeaorpe?a. - Compound surnames: in “de la Peña”, the first surname is not “de”.
- Duplicates: if
lpenaalready exists, the new account becomeslpena2.
function ConvertTo-Ascii([string]$Texto) {
# Split each letter from its accent (n + ~) and keep only the letter
$normal = $Texto.Normalize([Text.NormalizationForm]::FormD)
$sb = New-Object Text.StringBuilder
foreach ($c in $normal.ToCharArray()) {
if ([Globalization.CharUnicodeInfo]::GetUnicodeCategory($c) -ne 'NonSpacingMark') {
[void]$sb.Append($c)
}
}
$sb.ToString() -replace '[^a-zA-Z0-9]', ''
}
function Get-SamAccountName($Nombre, $Apellidos) {
$particulas = 'de', 'del', 'la', 'las', 'los', 'y'
$ape1 = ($Apellidos -split '\s+' | Where-Object { $_ -notin $particulas })[0]
$base = (ConvertTo-Ascii ($Nombre.Substring(0, 1) + $ape1)).ToLower()
$base = $base.Substring(0, [Math]::Min(18, $base.Length)) # the limit is 20
$sam = $base; $n = 1
while (Get-ADUser -Filter "SamAccountName -eq '$sam'") { $n++; $sam = "$base$n" }
$sam
}
3. Temporary password and account creation
The password comes from a cryptographic generator, not Get-Random. I also leave out characters that get confused when read aloud (l, I, O, 0):
function New-TempPassword {
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'
$bytes = New-Object byte[] 14
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
$pw = -join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] })
"$pw#$($bytes[0] % 10)" # always includes a symbol and a digit
}
Then the account gets created. Splatting (@params) lets you add the optional fields only when they’re filled in:
$sam = Get-SamAccountName $Nombre $Apellidos
$pw = New-TempPassword
$ou = "OU=$Departamento,OU=Usuarios,DC=your-domain,DC=local"
$params = @{
Name = "$Nombre $Apellidos"
GivenName = $Nombre
Surname = $Apellidos
SamAccountName = $sam
UserPrincipalName = "$sam@your-domain.com"
Path = $ou
Department = $Departamento
AccountPassword = ConvertTo-SecureString $pw -AsPlainText -Force
ChangePasswordAtLogon = $true
Enabled = $true
}
if ($Email) { $params.EmailAddress = $Email }
if ($Expira) { $params.AccountExpirationDate = $Expira }
New-ADUser @params
'GRP-Todos', "GRP-$Departamento" | ForEach-Object {
Add-ADGroupMember -Identity $_ -Members $sam
}
Groups always follow the same pattern: one shared by everyone and one per department. That way the script doesn’t need a separate list: the OU and group structure is the configuration. If a new department appears tomorrow, you just create its OU and its group.
4. The home folder (and the replication gotcha)
$ruta = "\\file-server\USUARIOS$\$sam"
New-Item -ItemType Directory -Path $ruta | Out-Null
$sid = (Get-ADUser $sam).SID
$acl = Get-Acl $ruta
$regla = New-Object Security.AccessControl.FileSystemAccessRule(
$sid, 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
$acl.AddAccessRule($regla)
Set-Acl -Path $ruta -AclObject $acl
Notice the rule uses the SID, not YOUR-DOMAIN\user. With several domain controllers, the new account may not have reached the DC the file server asks yet. If you use the name, you’ll get “Some or all identity references could not be translated”, and only sometimes, which is the worst kind of bug. With the SID there’s nothing to translate.
5. Audit log
Every onboarding, offboarding and deletion appends a line to a CSV: when, who, from which computer, what action and on which user.
function Write-Auditoria($Accion, $Usuario, $Detalle) {
[pscustomobject]@{
Fecha = Get-Date -Format 's'
Tecnico = $env:USERNAME
Equipo = $env:COMPUTERNAME
Accion = $Accion
Usuario = $Usuario
Detalle = $Detalle
} | Export-Csv -Path $LogAuditoria -Append -NoTypeInformation -Encoding UTF8
}
Write-Auditoria 'Alta' $sam "OU=$Departamento; grupos=GRP-Todos,GRP-$Departamento"
One rule worth remembering: the log says a password was generated, never what it is. The log is kept for years and more people can read it. If someone needs the temporary password, it goes through the channel you use to hand it over, and nowhere else.
If you use GLPI with directory users, you can trigger the sync over SSH at the end, so the person already exists in GLPI to get tickets or assigned devices:
ssh admin@glpi.your-domain.local "cd /var/www/glpi && sudo -u www-data php bin/console ldap:synchronize_users"
If you’re interested in the GLPI side, I wrote about wiring it to Wazuh in Wazuh + GLPI: automatic tickets and false positives.
6. Offboarding: disable, set aside, delete later
Offboarding deletes nothing on the spot. The account is disabled, its groups are recorded (in case the person returns, or you need to check what they had) and it’s moved to a leavers OU:
$u = Get-ADUser $sam -Properties MemberOf
Disable-ADAccount -Identity $u
Write-Auditoria 'Baja' $sam ("grupos=" + (($u.MemberOf | ForEach-Object { ($_ -split ',')[0] -replace 'CN=' }) -join ','))
$u.MemberOf | ForEach-Object { Remove-ADGroupMember -Identity $_ -Members $u -Confirm:$false }
Set-ADUser $u -Description "Baja $(Get-Date -Format yyyy-MM-dd)"
Move-ADObject -Identity $u -TargetPath 'OU=Bajas,DC=your-domain,DC=local'
Once a month, a scheduled task goes through that OU and deletes accounts that have been disabled for more than 180 days. The date is read from the description rather than whenChanged, which changes with any modification to the account:
$limite = (Get-Date).AddDays(-180)
Get-ADUser -SearchBase 'OU=Bajas,DC=your-domain,DC=local' -Filter * -Properties Description |
Where-Object { $_.Description -match '^Baja (\d{4}-\d{2}-\d{2})' -and [datetime]$Matches[1] -lt $limite } |
ForEach-Object {
Write-Auditoria 'Borrado' $_.SamAccountName 'Retencion 180 dias'
Remove-ADUser -Identity $_ -Confirm:$false -WhatIf # drop -WhatIf once you trust it
}
7. The weekly report
Another scheduled task emails IT a summary every Monday. It has the week’s joiners and leavers (taken from the audit CSV) and two lists that prevent nasty surprises:
# Accounts expiring in the next 30 days (interns, contracts)
Search-ADAccount -AccountExpiring -UsersOnly -TimeSpan 30.00:00:00
# Enabled accounts with no sign-in for 90 days
Search-ADAccount -AccountInactive -UsersOnly -TimeSpan 90.00:00:00 |
Where-Object Enabled
The second list is the one that catches leavers nobody told IT about, and every organisation has them.
8. PowerShell 5.1 details
The scripts have to run on the PowerShell that ships with Windows (5.1). There’s a trap there: if the .ps1 is UTF-8 without a BOM, PowerShell 5.1 reads it as ANSI and any accented characters in the code come out garbled. That’s why our scripts are pure ASCII: no accents in comments or messages. The other option is to save them as UTF-8 with a BOM.
A small installer puts a shortcut on the technician’s desktop that opens the window:
$ws = New-Object -ComObject WScript.Shell
$lnk = $ws.CreateShortcut("$env:USERPROFILE\Desktop\Gestion AD.lnk")
$lnk.TargetPath = 'powershell.exe'
$lnk.Arguments = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "C:\Scripts\AD\AD-Gui.ps1"'
$lnk.Save()
What not to do
- Hardcode passwords in the code. Not the mail account’s, not anyone’s. Use the
Microsoft.PowerShell.SecretManagementmodule, or at leastExport-Clixml, which encrypts with DPAPI for that user on that machine. - Write the temporary password to the log, a ticket or the team chat.
- Delete accounts at offboarding time. Disabling can be undone; deleting can’t.
- Assume email notifications work without testing them. Do a test onboarding every time the mail server changes. The day a notification goes missing will be a leaver’s.
- Use
Send-MailMessagefor anything new. Microsoft marks it as obsolete. If you do use it, point it at an internal relay, not a personal mailbox.
Summary
- One script for the window and the console: usernames without accents or duplicates, a cryptographic temporary password and the account created with
@params. - OUs and groups that follow a fixed pattern, so the directory structure is the configuration.
- Home folder permissions by SID, so replication doesn’t matter.
- A CSV audit log, with no passwords.
- Offboarding that disables and sets aside, deletion after 180 days, and a weekly report that catches forgotten accounts.
Automating onboarding saves time, but what really makes the difference is automating offboarding: it’s the part that’s always left half-done when you do it by hand.
Did it stick?
Three quick questions. Each right answer is worth 10 XP.