Las fotos de perfil en Microsoft 365 no son un adorno: aparecen en Teams, Outlook, SharePoint, Delve, el portal de Entra y prácticamente cualquier superficie donde se muestre una identidad de usuario. Una organización con las fotos actualizadas proyecta cohesión.
El problema aparece en cuanto necesitas ir más allá de un usuario. El centro de administración de Microsoft Entra permite cambiar la foto de perfil manualmente, pero solo de una cuenta cada vez. Para operaciones como un onboarding de decenas de empleados, una migración entre tenants o una simple auditoría de identidad corporativa, la interfaz gráfica no escala.
Aquí es donde entra Microsoft Graph PowerShell. Con cuatro cmdlets bien aplicados puedes auditar, descargar, establecer y eliminar las fotos de perfil de tantos usuarios como necesites.
Este artículo toma como punto de partida el trabajo de Ali Tajran en O365Info —una referencia sólida y bien documentada— y lo complementa con documentación oficial de Microsoft Learn, resolución de errores frecuentes, automatización programada y filtros prácticos para entornos reales.
1. Requisitos previos
1.1 Instalar el módulo Microsoft Graph

Lo primero es instalar el módulo Microsoft.Graph si todavía no lo tienes instalado. Se instala desde la PowerShell Gallery con una sola línea, ejecutada en una consola elevada:
Install-Module Microsoft.Graph -ForceConviene mantenerlo actualizado. Los cmdlets evolucionan con cierta frecuencia y una versión antigua puede devolver errores o carecer de parámetros que la documentación oficial ya recoge:
Update-Module Microsoft.GraphLa referencia oficial de instalación está en Microsoft Learn: Install the Microsoft Graph PowerShell SDK.
1.2 Conectarse con los permisos necesarios
El scope que necesitas para operar sobre fotos de perfil es User.ReadWrite.All. Permite leer y modificar propiedades de los usuarios del tenant, incluida la foto. No otorga acceso a Exchange ni a SharePoint; está acotado a la API de usuarios de Graph:
Connect-MgGraph -Scopes "User.ReadWrite.All"Referencia oficial: Connect-MgGraph.
1.3 Permisos en Entra y gobierno
El rol mínimo en Microsoft Entra ID para ejecutar estas operaciones es *User Administrator*. Si tu cuenta tiene *Global Administrator*, también funciona, pero aplica el principio de mínimo privilegio.
Una recomendación adicional, recogida por Microsoft, es restringir qué usuarios pueden modificar su propia foto de perfil mediante una OWA Mailbox Policy. Esto evita que cualquier empleado sustituya la foto corporativa por una imagen no profesional. Puedes consultar los detalles en O365Info — Prevent Microsoft 365 users from changing their photos.
1.4 Filtros avanzados y cambios en la API
Algunos de los filtros que aparecen en este artículo —en particular los que operan sobre colecciones como assignedLicenses/$count— utilizan capacidades de consulta avanzada de Microsoft Graph. Estas capacidades no están disponibles por defecto y requieren parámetros adicionales.
Según la documentación oficial de Microsoft sobre advanced queries:
*»The requestor must set the ConsistencyLevel header to
eventualand, except for$search, use the$countquery parameter.»*
En PowerShell, esto se traduce en añadir -ConsistencyLevel eventual -CountVariable count a los cmdlets Get-MgUser que usen filtros con $count, ne, not, endsWith u operadores sobre colecciones. El patrón exacto que muestra Microsoft Learn es:
Get-MgUser -Filter "accountEnabled ne true" -CountVariable CountVar -ConsistencyLevel eventualLos scripts de este artículo que filtran por assignedLicenses/$count incluyen estos parámetros. Si la API evoluciona o los filtros dejan de comportarse como se describe aquí, la tabla de propiedades de usuario en Microsoft Learn es la referencia canónica para saber qué operadores requieren consulta avanzada sobre cada propiedad.
2. Auditar: ¿qué usuarios tienen foto de perfil?
Antes de tocar nada, conviene saber desde dónde partes. ¿Cuántos usuarios de tu tenant tienen foto? ¿Cuántos no? ¿Qué dimensiones manejan?
2.1 Consulta individual
El cmdlet Get-MgUserPhoto devuelve los metadatos de la foto: un identificador y las dimensiones en píxeles. Necesitas el ObjectId del usuario:
Get-MgUserPhoto -UserId "a1b2c3d4-e5f6-7890-abcd-ef1234567890"Si el usuario tiene foto, obtendrás algo como:
Id Height Width
-- ------ -----
default 648 648Si no la tiene, el cmdlet lanza una excepción ImageNotFoundException con código HTTP 404. Esto no es un error de permisos ni de conectividad: simplemente indica que no hay foto asignada.
Referencia oficial: Get-MgUserPhoto.
2.2 Inventario masivo
Para obtener una radiografía completa del tenant, el script original de O365Info itera todos los usuarios y vuelca el resultado a un CSV. He añadido un par de líneas para filtrar usuarios sin licencia:
- Excluir usuarios sin licencia: las cuentas de tipo shared mailbox, room mailbox o contactos externos no necesitan foto. Aplicar
-Filter "assignedLicenses/$count ne 0"evita procesar objetos que no son usuarios reales. - Marcar explícitamente los usuarios sin foto: el CSV resultante incluye una columna
SinFotopara identificar rápidamente dónde actuar.
# Ruta de salida del CSV
$CSVPath = "C:tempInventarioFotos.csv"
# Conectar con permisos
Connect-MgGraph -Scopes "User.ReadWrite.All"
# Obtener solo usuarios con licencia asignada (requiere advanced query)
$users = Get-MgUser -All -Filter "assignedLicenses/`$count ne 0" -ConsistencyLevel eventual -CountVariable count
$results = foreach ($user in $users) {
$photo = Get-MgUserPhoto -UserId $user.Id -ErrorAction SilentlyContinue
$tieneFoto = $null -ne $photo
[PSCustomObject]@{
UserPrincipalName = $user.UserPrincipalName
DisplayName = $user.DisplayName
Department = $user.Department
TieneFoto = $tieneFoto
Height = if ($tieneFoto) { $photo.Height } else { $null }
Width = if ($tieneFoto) { $photo.Width } else { $null }
}
}
$results | Sort-Object TieneFoto, UserPrincipalName | Export-Csv -Path $CSVPath -NoTypeInformation -Encoding UTF8
Write-Host "Inventario completado. $($results.Count) usuarios procesados." -ForegroundColor Green
Write-Host "CSV generado en: $CSVPath"Con este CSV puedes priorizar: primero los departamentos sin foto, luego el resto. Es la base sobre la que construir el plan de acción.
Opción B — Script original de O365Info
>
El script anterior parte del trabajo de Ali Tajran. Si prefieres usar su versión original sin las adaptaciones de este artículo, está disponible en la sección *Get bulk all user photos* del artículo de referencia:
>
«`powershell
Connect-MgGraph -Scopes «User.ReadWrite.All»
$users = Get-MgUser -All
# … (ver script completo en el enlace inferior)
«`
>
👉 Ver script original en O365Info — Get bulk all user photos
3. Descargar fotos como respaldo
Antes de modificar nada, una copia de seguridad es barata y evita disgustos. Tanto si vas a hacer una migración como si simplemente quieres tener un backup local, Get-MgUserPhotoContent descarga el archivo binario de la foto.
3.1 Descarga individual
Get-MgUserPhotoContent -UserId "a1b2c3d4-e5f6-7890-abcd-ef1234567890" -OutFile "C:tempusuario@dominio.com.png"La extensión del archivo en -OutFile determina el formato de salida. Usa la misma extensión con la que quieras conservar la imagen.
Referencia oficial: Get-MgUserPhotoContent.
3.2 Respaldo masivo
El script original de O365Info resuelve la descarga masiva nombrando cada archivo con el UPN del usuario. La adaptación que incluyo aquí añade dos mejoras operativas:
- Control de errores por usuario: si un usuario concreto falla (por ejemplo, porque perdió la licencia entre el inventario y la descarga), el bucle continúa sin interrumpirse.
- Transcripción automática:
Start-Transcriptgenera un log de toda la operación, útil para auditoría o para revisar qué usuarios se saltaron.
# Carpeta de destino
$photoFolderPath = "C:tempUserPhotos"
New-Item -ItemType Directory -Force -Path $photoFolderPath | Out-Null
# Iniciar transcripción para auditoría
Start-Transcript -Path "$photoFolderPathdescarga.log" -Append
# Conectar
Connect-MgGraph -Scopes "User.ReadWrite.All"
# Usuarios con licencia (requiere advanced query)
$users = Get-MgUser -All -Filter "assignedLicenses/`$count ne 0" -ConsistencyLevel eventual -CountVariable count
foreach ($user in $users) {
try {
$photo = Get-MgUserPhoto -UserId $user.Id -ErrorAction Stop
$photoPath = Join-Path $photoFolderPath "$($user.UserPrincipalName).png"
Get-MgUserPhotoContent -UserId $user.Id -OutFile $photoPath -ErrorAction Stop
Write-Host "Descargada: $($user.UserPrincipalName)" -ForegroundColor Green
}
catch {
Write-Host "Sin foto o error: $($user.UserPrincipalName) — $($_.Exception.Message)" -ForegroundColor Yellow
}
}
Stop-TranscriptOpción B — Script original de O365Info
>
El script de descarga masiva está basado en el original de O365Info. Si quieres usar la versión sin adaptaciones:
>
«`powershell
Connect-MgGraph -Scopes «User.ReadWrite.All»
$users = Get-MgUser -All
# … (ver script completo en el enlace inferior)
«`
>
👉 Ver script original en O365Info — Download bulk all user photos
4. Establecer fotos de perfil
Llegamos a la operación más habitual: subir o reemplazar la foto de perfil. El cmdlet es Set-MgUserPhotoContent y conviene conocer bien su comportamiento antes de lanzarlo contra un tenant en producción.
4.1 Subida individual
Set-MgUserPhotoContent -UserId "a1b2c3d4-e5f6-7890-abcd-ef1234567890" -InFile "C:tempfoto.png" Importante: si el usuario ya tiene una foto, Set-MgUserPhotoContent la sobrescribe sin confirmación ni advertencia. No hay vuelta atrás salvo que tengas el respaldo del punto 3.
Referencia oficial: Set-MgUserPhotoContent.
4.2 Requisitos de la imagen
Según la documentación oficial de Microsoft (Add or change profile photo in Microsoft 365), los límites son:
| Parámetro | Valor |
|---|---|
| Tamaño máximo | 4 MB |
| Formatos admitidos | PNG, JPEG, JPG, BMP |
| Resolución máxima | Sin límite documentado (probado hasta 10.000 × 10.000 px) |
Si necesitas reducir una imagen que supera los 4 MB, puedes hacerlo desde el propio PowerShell sin recurrir a herramientas online. El siguiente fragmento carga la imagen, la redimensiona al 50% y la guarda con un nivel de compresión JPEG del 80%:
Add-Type -AssemblyName System.Drawing
$original = [System.Drawing.Image]::FromFile("C:tempfoto_grande.jpg")
$ancho = [int]($original.Width * 0.5)
$alto = [int]($original.Height * 0.5)
$redimensionada = New-Object System.Drawing.Bitmap($ancho, $alto)
$graphics = [System.Drawing.Graphics]::FromImage($redimensionada)
$graphics.DrawImage($original, 0, 0, $ancho, $alto)
$graphics.Dispose()
$encoder = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq "image/jpeg" }
$params = New-Object System.Drawing.Imaging.EncoderParameters(1)
$params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality, 80L)
$redimensionada.Save("C:tempfoto_reducida.jpg", $encoder, $params)
$original.Dispose()
$redimensionada.Dispose()Esto mantiene las imágenes en local, sin exponerlas a servicios de terceros.
4.3 Subida masiva por nombre de archivo
La estrategia más directa para una subida masiva consiste en nombrar cada imagen con el UserPrincipalName del usuario (por ejemplo, borja@millaredos.com.png) y dejar que el script haga el cruce automático. Esta es la base del script de O365Info, con tres variantes añadidas.
Variante A — Subida masiva estándar (script base, atribuido a O365Info):
$PhotoLocation = "C:tempUserPhotos"
Connect-MgGraph -Scopes "User.ReadWrite.All"
$Users = Get-MgUser -All -Filter "assignedLicenses/`$count ne 0" -ConsistencyLevel eventual -CountVariable count
$i = 0
foreach ($User in $Users) {
$UserPhoto = $PhotoLocation + $User.UserPrincipalName
$UserPhotoExtensions = @(".png", ".jpg", ".jpeg", ".bmp")
$UserPhotoPath = $UserPhotoExtensions | Where-Object { Test-Path ($UserPhoto + $_) } | Select-Object -First 1
if ($UserPhotoPath) {
Write-Host "Subiendo foto para: $($User.UserPrincipalName)" -ForegroundColor Green
Set-MgUserPhotoContent -UserId $User.Id -InFile ($UserPhoto + $UserPhotoPath)
$i++
}
else {
Write-Host "Sin archivo para: $($User.UserPrincipalName)" -ForegroundColor Yellow
}
}
Write-Host "Completado. $i fotos subidas." -ForegroundColor GreenVariante B — Solo usuarios sin foto:
Si ya tienes parte del tenant con fotos personalizadas y solo quieres cubrir los huecos, añade una comprobación previa. Esta variante responde al caso real planteado por varios lectores de O365Info:
# Dentro del bucle foreach, después de obtener $User:
$existingPhoto = Get-MgUserPhoto -UserId $User.Id -ErrorAction SilentlyContinue
if ($null -ne $existingPhoto) {
Write-Host "Ya tiene foto, se omite: $($User.UserPrincipalName)" -ForegroundColor DarkGray
continue
}
# El resto del código de subida va aquíVariante C — Desde un CSV con pares (UPN, ruta):
Cuando los nombres de archivo no pueden coincidir con el UPN (por ejemplo, porque provienen de un sistema de RRHH con otro convenio de nombres), una alternativa es usar un CSV como fuente:
UserPrincipalName,RutaImagen
ana.garcia@millaredos.com,C:fotosANA_GARCIA.jpg
carlos.lopez@millaredos.com,C:fotosCLOPEZ_2026.pngEl script lee el CSV y procesa solo las filas indicadas:
$csv = Import-Csv "C:tempmapeo_fotos.csv"
foreach ($row in $csv) {
$user = Get-MgUser -UserId $row.UserPrincipalName -ErrorAction SilentlyContinue
if ($user -and (Test-Path $row.RutaImagen)) {
Set-MgUserPhotoContent -UserId $user.Id -InFile $row.RutaImagen
Write-Host "Subida: $($row.UserPrincipalName)" -ForegroundColor Green
}
}Esta variante te da control total sobre qué foto va a cada usuario, sin depender del nombre del archivo.
Opción B — Script original de O365Info
>
Las variantes A, B y C parten del script de subida masiva de O365Info. Para consultar la versión original:
>
«`powershell
Connect-MgGraph -Scopes «User.ReadWrite.All»
$Users = Get-MgUser -All
# … (ver script completo en el enlace inferior)
«`
>
👉 Ver script original en O365Info — Set user photo for multiple accounts
5. Eliminar fotos de perfil
Hay escenarios donde eliminar la foto es la acción correcta: cumplimiento de políticas de privacidad, baja de un empleado, o estandarización previa a una nueva campaña de fotos.
5.1 Eliminación individual
Remove-MgUserPhoto -UserId "a1b2c3d4-e5f6-7890-abcd-ef1234567890"El parámetro -Confirm:$False suprime la confirmación interactiva. Úsalo con conocimiento de causa y solo en scripts donde ya hayas validado el alcance.
Referencia oficial: Remove-MgUserPhoto.
5.2 Eliminación masiva controlada
El script original de O365Info elimina la foto de todos los usuarios. Para un entorno real, conviene acotar el alcance. Esta versión añade un filtro por departamento:
$departamento = "Ventas"
$users = Get-MgUser -All -Filter "Department eq '$departamento'"
foreach ($user in $users) {
$photo = Get-MgUserPhoto -UserId $user.Id -ErrorAction SilentlyContinue
if ($null -ne $photo) {
Remove-MgUserPhoto -UserId $user.Id -Confirm:$False
Write-Host "Eliminada foto de: $($user.UserPrincipalName)" -ForegroundColor Green
}
else {
Write-Host "Sin foto: $($user.UserPrincipalName)" -ForegroundColor Yellow
}
}Puedes sustituir el filtro por Department, City, JobTitle o cualquier atributo disponible en Get-MgUser. La clave es no lanzar una eliminación contra el tenant entero sin un criterio claro.
Opción B — Script original de O365Info
>
El script anterior adapta el original de O365Info añadiendo filtro por departamento. Para ver la versión original que elimina las fotos de todos los usuarios:
>
«`powershell
$users = Get-MgUser -All
# … (ver script completo en el enlace inferior)
«`
>
👉 Ver script original en O365Info — Remove bulk all user photos
6. Errores frecuentes y su solución
Esta sección recoge los cuatro errores más comunes al trabajar con fotos de perfil en Microsoft Graph, basados en experiencia real con tenants de distintos tamaños.
6.1 «Insufficient privileges» al ejecutar cualquier cmdlet
Causa: la sesión de Graph se autenticó sin el scope User.ReadWrite.All, o el scope se concedió pero el usuario autenticado no tiene el rol de administrador necesario en Entra.
Solución: cierra la sesión actual y vuelve a conectar:
Disconnect-MgGraph
Connect-MgGraph -Scopes "User.ReadWrite.All"Verifica el rol del usuario en el portal de Entra, en *Identity > Users > Assigned roles*.
6.2 «ImageNotFoundException» (HTTP 404)
Causa: el usuario no tiene foto de perfil asignada.
Solución: no es un error que requiera acción. El cmdlet funciona correctamente. En los scripts de auditoría o descarga masiva, captúralo con -ErrorAction SilentlyContinue para evitar que el bucle se detenga.
6.3 «Request_ResourceNotFound» al hacer Set-MgUserPhotoContent
Causa: el identificador proporcionado corresponde a un objeto que no es un usuario estándar (shared mailbox, room mailbox, guest sin licencia, o un contacto externo). Estos objetos no admiten foto de perfil.
Solución: filtra los usuarios antes de operar. La propiedad UserType debe ser Member:
Get-MgUser -All -Filter "UserType eq 'Member'"6.4 Timeout en tenants grandes
Causa: Get-MgUser -All sin paginación explícita puede agotar el tiempo de espera en tenants con más de 10.000 usuarios.
Solución: añade -PageSize para controlar el tamaño de cada lote:
Get-MgUser -All -PageSize 500Esto reduce la carga en memoria y evita timeouts, a costa de un procesamiento ligeramente más lento.
7. Automatización programada
Disponer de los scripts es el primer paso. El segundo es que se ejecuten sin intervención manual. Para un entorno corporativo, el inventario de fotos y la detección de usuarios sin foto deberían correr de forma periódica.
7.1 Autenticación desatendida con certificado
La conexión interactiva (Connect-MgGraph -Scopes) no sirve para una tarea programada. Necesitas autenticación basada en certificado. El flujo completo está documentado en Microsoft Learn (App-only authentication), pero en esencia:
- Registras una aplicación en Entra.
- Le asignas el permiso
User.ReadWrite.Allde tipo *Application* (no *Delegated*). - Subes un certificado autofirmado.
- Conectas desde el script así:
Connect-MgGraph -ClientId "tu-client-id" -TenantId "tu-tenant-id" -CertificateThumbprint "huella"7.2 Programación y alertas
Con la autenticación resuelta, puedes empaquetar el script de inventario (sección 2.2) en una tarea programada de Windows o en un Runbook de Azure Automation. Como complemento, si el porcentaje de usuarios sin foto supera un umbral (por ejemplo, el 10%), el script puede enviar una alerta a un canal de Teams mediante un webhook entrante:
$sinFoto = ($results | Where-Object { -not $_.TieneFoto }).Count
$total = $results.Count
if ($total -eq 0) {
Write-Host "No se encontraron usuarios con licencia. Verifica el filtro o el tenant." -ForegroundColor Yellow
}
else {
$pct = [math]::Round(($sinFoto / $total) * 100, 1)
if ($pct -gt 10) {
$body = @{ text = "⚠️ El $pct% de los usuarios ($sinFoto de $total) no tiene foto de perfil." } | ConvertTo-Json
Invoke-RestMethod -Uri "https://tu-tenant.webhook.office.com/..." -Method Post -Body $body -ContentType "application/json"
}
}Delegar esta comprobación a una automatización te quita el trabajo recurrente y te avisa solo cuando hay que actuar.
8. Resumen
La gestión de fotos de perfil en Microsoft 365 se apoya en cuatro cmdlets de Microsoft Graph:
| Operación | Cmdlet | Microsoft Learn |
|---|---|---|
| Consultar metadatos | `Get-MgUserPhoto` | [Documentación](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.users/get-mguserphoto) |
| Descargar archivo | `Get-MgUserPhotoContent` | [Documentación](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.users/get-mguserphotocontent) |
| Establecer / subir | `Set-MgUserPhotoContent` | [Documentación](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.users/set-mguserphotocontent) |
| Eliminar | `Remove-MgUserPhoto` | [Documentación](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.users/remove-mguserphoto) |
El flujo de trabajo recomendado es: auditar → preparar imágenes → subir → verificar → programar. Con los scripts de este artículo puedes cubrir cada etapa, desde el inventario inicial hasta la automatización periódica con alertas.
Si quieres profundizar en variantes adicionales o ver los scripts originales que inspiraron este artículo, el trabajo de Ali Tajran en O365Info — Manage User Photos with Microsoft Graph PowerShell es la referencia de partida.
*¿Te ha resultado útil este artículo? Compártelo con algún compañero que todavía esté subiendo fotos una a una desde el portal de Entra.*


