Qué vas a conseguir
Un script que crea en Active Directory los usuarios de un CSV (cada uno en su OU, con sus grupos y una contraseña temporal que nadie ve por correo), que se puede lanzar dos veces sin crear duplicados y que deja un registro de lo que hizo. Y su pareja para las bajas: primero un plan que alguien revisa, después la ejecución, y siempre con los grupos exportados para poder deshacer.
Antes de empezar
- El módulo ActiveDirectory (RSAT en tu equipo o en un servidor de gestión) y una cuenta con permisos delegados para crear y mover usuarios en las OU de destino, no Domain Admin.
- Las OU y los grupos ya creados, y la convención de nombres de la casa.
- Si hay Azure AD Connect o Cloud Sync, saber qué OU están en el ámbito de sincronización: lo que hagas aquí aparece en Entra ID en el siguiente ciclo.
1. El CSV
Una fila por persona. Recursos Humanos o el responsable lo rellena; tú lo revisas.
Nombre;Apellidos;Usuario;Departamento;Puesto;OU;GruposLaura;Prado Ruiz;laura.prado;Finanzas;Analista;OU=Finanzas,OU=Usuarios,DC=contoso,DC=local;GRP-Finanzas|GRP-VPNMarcos;Gil Soto;marcos.gil;Operaciones;Técnico;OU=Operaciones,OU=Usuarios,DC=contoso,DC=local;GRP-Operaciones- Separador
;si el CSV sale de un Excel en español, y guardado como CSV UTF-8 para que las tildes y la ñ lleguen bien. - Los grupos de una persona, separados por
|dentro de la misma columna. - Ninguna contraseña en el CSV. Nunca.
2. El script de altas
[CmdletBinding(SupportsShouldProcess)]param([Parameter(Mandatory)][string]$Csv, [string]$Log = ".\altas-$(Get-Date -Format yyyyMMdd-HHmm).csv")Import-Module ActiveDirectoryfunction New-TempPassword { $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!#%+?' $bytes = New-Object byte[] 16 [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) -join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] })}$result = foreach ($r in Import-Csv $Csv -Delimiter ';' -Encoding UTF8) { $sam = $r.Usuario.Trim().ToLower() if (Get-ADUser -Filter "SamAccountName -eq '$sam'") { [pscustomobject]@{ Usuario = $sam; Estado = 'Ya existe'; Detalle = 'No se toca' }; continue } try { $pw = New-TempPassword $p = @{ Name = "$($r.Nombre) $($r.Apellidos)"; GivenName = $r.Nombre; Surname = $r.Apellidos SamAccountName = $sam; UserPrincipalName = "$sam@contoso.com"; DisplayName = "$($r.Nombre) $($r.Apellidos)" Department = $r.Departamento; Title = $r.Puesto; Path = $r.OU; Enabled = $true ChangePasswordAtLogon = $true; AccountPassword = (ConvertTo-SecureString $pw -AsPlainText -Force) } New-ADUser @p -ErrorAction Stop foreach ($g in $r.Grupos -split '\|' | Where-Object { $_ }) { Add-ADGroupMember -Identity $g -Members $sam -ErrorAction Stop } if (-not $WhatIfPreference) { Write-Host "$sam -> contraseña temporal: $pw" } [pscustomobject]@{ Usuario = $sam; Estado = $(if ($WhatIfPreference) { 'Simulado' } else { 'Creado' }); Detalle = $r.Grupos } } catch { [pscustomobject]@{ Usuario = $sam; Estado = 'Error'; Detalle = $_.Exception.Message } }}$result | Export-Csv $Log -NoTypeInformation -Encoding UTF8 -Delimiter ';'Lo que hace cada pieza:
- Idempotencia: antes de crear, busca el usuario con
Get-ADUser -Filter. Con-Identitydaría error si no existe; con-Filterno devuelve nada. Así el script se puede relanzar tras corregir una fila sin duplicar a nadie. - Contraseña: una distinta por usuario, generada con un generador criptográfico, y con
ChangePasswordAtLogonpara que la persona ponga la suya el primer día. - try/catch por usuario: un fallo en una fila no para el resto.
- El registro: un CSV con usuario, estado y detalle. Se adjunta al ticket, sin contraseñas.
3. Siempre primero con -WhatIf
.\Nuevo-UsuariosAD.ps1 -Csv .\altas-2026-11.csv -WhatIfWhat if: Performing the operation "New" on target "CN=Laura Prado Ruiz,OU=Finanzas,OU=Usuarios,DC=contoso,DC=local".What if: Performing the operation "Set" on target "GRP-Finanzas".What if: Performing the operation "Set" on target "GRP-VPN"..\Nuevo-UsuariosAD.ps1 -Csv .\altas-2026-11.csvComo el script declara SupportsShouldProcess, -WhatIf pasa a New-ADUser y Add-ADGroupMember: enseñan lo que harían sin hacerlo.
4. Entregar la contraseña
La contraseña temporal sale una vez por pantalla y no se guarda. Opciones razonables:
- Dársela al responsable en persona o por teléfono.
- No entregarla: el primer día la persona llama al service desk, se verifica su identidad y se le hace un restablecimiento con cambio obligatorio.
- Con Entra ID, un Temporary Access Pass para registrar MFA.
Nunca en el ticket, en un correo o en el mismo mensaje que el nombre de usuario. Y si usas Read-Host -AsSecureString para teclear una contraseña común para todo el lote, que sea solo para una prueba: una contraseña inicial igual para todos acaba siendo conocida por todos.
5. Las bajas: plan, revisión y ejecución
Para las bajas se cambia el orden: primero se genera un plan que otra persona revisa, y solo después se aplica.
$bajas = Import-Csv .\bajas-2026-11.csv -Delimiter ';'$plan = foreach ($b in $bajas) { Get-ADUser -Filter "SamAccountName -eq '$($b.Usuario)'" -Properties MemberOf, Enabled, Department, LastLogonDate | Select-Object SamAccountName, Name, Department, Enabled, LastLogonDate, @{n='Grupos';e={$_.MemberOf.Count}} }$plan | Export-Csv .\plan-bajas-2026-11.csv -NoTypeInformation -Encoding UTF8 -Delimiter ';'El revisor comprueba que son las personas correctas (no un homónimo) y que nadie sigue trabajando. Con su visto bueno en el ticket:
foreach ($u in $plan) { $user = Get-ADUser $u.SamAccountName -Properties MemberOf $user.MemberOf | Set-Content ".\grupos-$($u.SamAccountName).txt" -Encoding UTF8 Disable-ADAccount -Identity $user Set-ADUser -Identity $user -Description "Baja 2026-11-03 · ticket INC-48213" if ($user.MemberOf) { Remove-ADPrincipalGroupMembership -Identity $user -MemberOf $user.MemberOf -Confirm:$false } Move-ADObject -Identity $user.DistinguishedName -TargetPath "OU=Bajas,DC=contoso,DC=local"}- Exportar los grupos primero: si la baja era un error o la persona vuelve, con ese fichero se le devuelven los permisos en un minuto.
- Deshabilitar, no borrar: el borrado se hace pasado el plazo acordado (30, 60 o 90 días), con la papelera de reciclaje de AD activada.
- Domain Users es el grupo principal y no aparece en
MemberOf, así que no se quita.
Si la OU Bajas está fuera del ámbito de sincronización de Azure AD Connect, mover el usuario allí hace que se borre en Entra ID en el siguiente ciclo (queda 30 días en usuarios eliminados) y con él su buzón y su OneDrive pasan a sus plazos de retención. Decídelo antes, no después.
Siguientes pasos
- La guía de PowerShell de este mismo nivel explica el patrón de objetos,
try/catchy CSV que usa este script. - La guía de Azure AD Connect explica el ámbito de sincronización y qué pasa en Entra ID con cada movimiento de OU.
- Las cuentas que nadie dio de baja se encuentran con una revisión periódica. Es la idea de los repositorios 37-iam-access-review y 03-entra-id-hygiene-audit de mi GitHub.
¿Falta algo o en tu entorno es distinto? Escríbeme y lo corrijo.
What you'll get
A script that creates in Active Directory the users in a CSV (each one in their OU, with their groups and a temporary password nobody sees by email), that can be run twice without creating duplicates and that leaves a log of what it did. And its counterpart for leavers: first a plan that someone reviews, then the run, and always with the groups exported so you can undo it.
Before you start
- The ActiveDirectory module (RSAT on your device or on a management server) and an account with delegated permissions to create and move users in the target OUs, not Domain Admin.
- The OUs and groups already created, and the organisation's naming convention.
- If there is Azure AD Connect or Cloud Sync, knowing which OUs are in sync scope: whatever you do here shows up in Entra ID on the next cycle.
1. The CSV
One row per person. HR or the manager fills it in; you review it.
FirstName;LastName;User;Department;Title;OU;GroupsLaura;Prado Ruiz;laura.prado;Finance;Analyst;OU=Finance,OU=Users,DC=contoso,DC=local;GRP-Finance|GRP-VPNMarcos;Gil Soto;marcos.gil;Operations;Technician;OU=Operations,OU=Users,DC=contoso,DC=local;GRP-Operations- A
;separator if the CSV comes out of a Spanish-locale Excel, and saved as CSV UTF-8 so accents and special characters arrive intact. - A person's groups separated by
|inside the same column. - No passwords in the CSV. Ever.
2. The starters script
[CmdletBinding(SupportsShouldProcess)]param([Parameter(Mandatory)][string]$Csv, [string]$Log = ".\starters-$(Get-Date -Format yyyyMMdd-HHmm).csv")Import-Module ActiveDirectoryfunction New-TempPassword { $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!#%+?' $bytes = New-Object byte[] 16 [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) -join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] })}$result = foreach ($r in Import-Csv $Csv -Delimiter ';' -Encoding UTF8) { $sam = $r.User.Trim().ToLower() if (Get-ADUser -Filter "SamAccountName -eq '$sam'") { [pscustomobject]@{ User = $sam; Status = 'Already exists'; Detail = 'Left untouched' }; continue } try { $pw = New-TempPassword $p = @{ Name = "$($r.FirstName) $($r.LastName)"; GivenName = $r.FirstName; Surname = $r.LastName SamAccountName = $sam; UserPrincipalName = "$sam@contoso.com"; DisplayName = "$($r.FirstName) $($r.LastName)" Department = $r.Department; Title = $r.Title; Path = $r.OU; Enabled = $true ChangePasswordAtLogon = $true; AccountPassword = (ConvertTo-SecureString $pw -AsPlainText -Force) } New-ADUser @p -ErrorAction Stop foreach ($g in $r.Groups -split '\|' | Where-Object { $_ }) { Add-ADGroupMember -Identity $g -Members $sam -ErrorAction Stop } if (-not $WhatIfPreference) { Write-Host "$sam -> temporary password: $pw" } [pscustomobject]@{ User = $sam; Status = $(if ($WhatIfPreference) { 'Simulated' } else { 'Created' }); Detail = $r.Groups } } catch { [pscustomobject]@{ User = $sam; Status = 'Error'; Detail = $_.Exception.Message } }}$result | Export-Csv $Log -NoTypeInformation -Encoding UTF8 -Delimiter ';'What each piece does:
- Idempotency: before creating, it looks the user up with
Get-ADUser -Filter. With-Identityit would throw an error if the user does not exist; with-Filterit returns nothing. So the script can be rerun after fixing a row without duplicating anyone. - Password: a different one per user, made with a cryptographic generator, and with
ChangePasswordAtLogonso the person sets their own on day one. - try/catch per user: a failure on one row does not stop the rest.
- The log: a CSV with user, status and detail. It goes into the ticket, with no passwords.
3. Always with -WhatIf first
.\New-ADUsers.ps1 -Csv .\starters-2026-11.csv -WhatIfWhat if: Performing the operation "New" on target "CN=Laura Prado Ruiz,OU=Finance,OU=Users,DC=contoso,DC=local".What if: Performing the operation "Set" on target "GRP-Finance".What if: Performing the operation "Set" on target "GRP-VPN"..\New-ADUsers.ps1 -Csv .\starters-2026-11.csvBecause the script declares SupportsShouldProcess, -WhatIf flows down to New-ADUser and Add-ADGroupMember: they show what they would do without doing it.
4. Handing over the password
The temporary password appears once on screen and is not stored. Reasonable options:
- Give it to the manager in person or by phone.
- Don't hand it over: on day one the person calls the service desk, their identity is verified and they get a reset with a forced change.
- With Entra ID, a Temporary Access Pass to register MFA.
Never in the ticket, in an email or in the same message as the username. And if you use Read-Host -AsSecureString to type one shared password for the whole batch, keep it to a test: an initial password that is the same for everyone ends up known by everyone.
5. Leavers: plan, review and run
For leavers the order changes: first you generate a plan that someone else reviews, and only then is it applied.
$leavers = Import-Csv .\leavers-2026-11.csv -Delimiter ';'$plan = foreach ($b in $leavers) { Get-ADUser -Filter "SamAccountName -eq '$($b.User)'" -Properties MemberOf, Enabled, Department, LastLogonDate | Select-Object SamAccountName, Name, Department, Enabled, LastLogonDate, @{n='Groups';e={$_.MemberOf.Count}} }$plan | Export-Csv .\leavers-plan-2026-11.csv -NoTypeInformation -Encoding UTF8 -Delimiter ';'The reviewer checks they are the right people (not a namesake) and that nobody is still working. With their approval in the ticket:
foreach ($u in $plan) { $user = Get-ADUser $u.SamAccountName -Properties MemberOf $user.MemberOf | Set-Content ".\groups-$($u.SamAccountName).txt" -Encoding UTF8 Disable-ADAccount -Identity $user Set-ADUser -Identity $user -Description "Leaver 2026-11-03 · ticket INC-48213" if ($user.MemberOf) { Remove-ADPrincipalGroupMembership -Identity $user -MemberOf $user.MemberOf -Confirm:$false } Move-ADObject -Identity $user.DistinguishedName -TargetPath "OU=Leavers,DC=contoso,DC=local"}- Export the groups first: if the leaver was a mistake or the person comes back, that file gives them their access back in a minute.
- Disable, don't delete: deletion happens after the agreed period (30, 60 or 90 days), with the AD Recycle Bin enabled.
- Domain Users is the primary group and does not appear in
MemberOf, so it is not removed.
If the Leavers OU is outside Azure AD Connect's sync scope, moving the user there deletes it in Entra ID on the next cycle (it stays 30 days in deleted users), and their mailbox and OneDrive move into their retention periods. Decide beforehand, not afterwards.
Next steps
- The PowerShell guide at this same level explains the objects,
try/catchand CSV pattern this script uses. - The Azure AD Connect guide explains sync scope and what happens in Entra ID with each OU move.
- The accounts nobody offboarded are found with a periodic review. That is the idea behind the 37-iam-access-review and 03-entra-id-hygiene-audit repositories on my GitHub.
Is something missing, or different in your environment? Get in touch and I'll fix it.
Tech Career Lab · newsletter
¿Te ha servido? Cada dos semanas envío una idea práctica para perfiles IT: LinkedIn, proyectos, networking y procesos de selección.Found this useful? Every two weeks I send one practical idea for IT professionals: LinkedIn, projects, networking and hiring processes.
Apúntate y llévate las 33 ideasSign up and get the 33 ideas →