Gestión de fotos de perfil en Microsoft 365 con Microsoft Graph PowerShell

Con cuatro cmdlets de Microsoft Graph PowerShell puedes auditar, descargar, establecer y eliminar fotos de perfil de tantos usuarios como necesites.

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

Terminal de PowerShell con comandos de instalación del 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 -Force

Conviene 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.Graph

La 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 eventual and, except for $search, use the $count query 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 eventual

Los 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    648

Si 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 SinFoto para 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-Transcript genera 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-Transcript

Opció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ámetroValor
Tamaño máximo4 MB
Formatos admitidosPNG, JPEG, JPG, BMP
Resolución máximaSin 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 Green

Variante 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.png

El 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 500

Esto 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:

  1. Registras una aplicación en Entra.
  2. Le asignas el permiso User.ReadWrite.All de tipo *Application* (no *Delegated*).
  3. Subes un certificado autofirmado.
  4. 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ónCmdletMicrosoft 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.*

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *