Windows Account Pictures, Done Properly: Pluggable Sources and Fallbacks in PowerShell

If you look after a handful of Windows machines, you have probably noticed that the sign-in screen is a sea of grey silhouettes. If some of those PCs are joined to a domain or registered with Entra ID, you may have seen something worse: you set a nice picture, and a day later it is gone, replaced by a generic “ID badge” icon.

This post covers where Windows actually keeps sign-in pictures, why they get overwritten, and a self-contained PowerShell script that sets them from a chain of sources: Gravatar, a Git repository, or a plain URL. It tries each one in order and falls back sensibly. The script works the same on a standalone PC and on a domain-joined one.

Where Windows keeps the sign-in picture

You might expect the sign-in screen to use the thumbnailPhoto attribute from Active Directory. It doesn’t. Outlook and Teams use that attribute; LogonUI never reads it.

What the sign-in screen does read is a per-PC registry key, one per user SID:

HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AccountPicture\Users\<SID>

It holds eleven string values: Image32, Image40, Image48, Image64, Image96, Image192, Image208, Image240, Image424, Image448 and Image1080. Each value is the path to a JPEG of that size, normally under:

C:\Users\Public\AccountPictures\<SID>\

Both locations are locked down. Writing them needs admin rights at a minimum, and in practice it is far more reliable as SYSTEM. The script handles this for you (see below).

The trap: Windows replacing your picture

On a PC registered as a work device (Entra ID joined or registered, or registered through AD FS device registration), Windows runs two built-in scheduled tasks:

\Microsoft\Windows\Shell\UpdateUserPictureTask
\Microsoft\Windows\Shell\UpdateUserPictureTaskContained

They fetch the user’s picture from the identity provider. If the provider has no photo, as with AD FS, Windows writes a generic ID-badge image over whatever you set. Your picture comes back every time you set it and disappears again on the next run.

The fix is to disable both tasks, and there are two traps:

  • Only SYSTEM can disable them. Run Disable-ScheduledTask against them from an elevated
    admin prompt and it appears to succeed, but the tasks stay Ready. No error, no change.
  • Group Policy Preferences can’t do it either. These tasks run as the Interactive group and
    use a COM-handler action, and a GPP Scheduled Task item rejects them with 0x80041319.

So the script disables them as SYSTEM, and then checks the task state afterwards instead of trusting the cmdlet.

Picture sources, and why “not found” is different from “failed”

The script takes a list of sources and tries them in order. Each source returns one of three answers, and the difference between them is the most important design decision in the script:

ResultMeaningWhat the chain does
FoundHere is the imageUse it and stop
NotFoundThis source definitely has no picture for this personTry the next source
ErrorNetwork down, server error, bad hash, broken configStop and change nothing

If an outage were treated as “not found”, one bad morning at Gravatar would replace everyone’s real photo with the fallback. Treating errors as “stop” means the worst an outage can do is delay an update.

There are three source types.

gravatar: (Gravatar)

gravatar:jane@example.com

Gravatar identifies a person by a hash of their email address. Gravatar’s current guidance is SHA-256 of the trimmed, lower-cased address; MD5 still works, but it is the legacy form. The script asks for:

https://gravatar.com/avatar/<sha256>?s=1080&d=404

d=404 is the useful part. Without it, Gravatar returns a placeholder image for unknown addresses, and you can’t tell “no Gravatar” from a real picture. With it, an unknown address is a genuine HTTP 404, so the result is NotFound and the chain moves on.

$email = $Value.Trim().ToLowerInvariant()
$hash  = Get-Sha256Hex ([Text.Encoding]::UTF8.GetBytes($email))
$response = Invoke-HttpGet "https://gravatar.com/avatar/${hash}?s=1080&d=404"
switch ($response.StatusCode) {
    200     { ConvertTo-SourceResult Found $response.Bytes "Gravatar for $email" }
    404     { ConvertTo-SourceResult NotFound $null "no Gravatar for $email" }
    default { ConvertTo-SourceResult Error $null "Gravatar returned HTTP $($response.StatusCode)" }
}

git: (a manifest in a Git repository)

git:https://raw.githubusercontent.com/example/avatars/main/manifest.json
git:https://raw.githubusercontent.com/example/avatars/main/manifest.json#jdoe

This suits pictures you curate yourself: headshots, a team logo for shared accounts, and so on. Put the images in a public repository (GitHub, GitLab, Gitea, anything with raw file URLs) alongside a manifest.json that records each file’s SHA-256:

{
  "pictures": [
    { "name": "jdoe",   "file": "pictures/jdoe.png",   "sha256": "1b350d2e93fb415ea1b98e8af025e62157f23d1d1e16db111d2c8d91f402566b" },
    { "name": "kiosk",  "file": "pictures/kiosk.png",  "sha256": "…" }
  ]
}

file is relative to the manifest. The name after # picks the entry. Without it, the script uses the account’s user name, so one source line can serve everyone on the PC. To produce a hash for the manifest:

(Get-FileHash .\pictures\jdoe.png -Algorithm SHA256).Hash.ToLower()

The script downloads the image and rejects it if the hash doesn’t match. A user missing from the manifest is NotFound. A missing manifest, a missing file, or a hash mismatch is an Error, because each of those means the repository is broken, not that the person has no picture.

url: (any image URL)

url:https://example.com/pictures/default.png
url:https://example.com/pictures/default.png#sha256=1b350d2e…

This is the simplest source and makes a good last link in a chain, such as a company logo for anyone with no picture elsewhere. A 404 is NotFound, and the optional #sha256= pins the content.

Building a fallback chain

Because sources are just a list, the order is your policy:

# People's own Gravatar first, then your curated picture, then a default.
-Source 'gravatar:jane@example.com',
        'git:https://raw.githubusercontent.com/example/avatars/main/manifest.json',
        'url:https://example.com/pictures/default.png'

# The curated picture wins; Gravatar only fills gaps.
-Source 'git:https://raw.githubusercontent.com/example/avatars/main/manifest.json',
        'gravatar:jane@example.com'

The chain loop itself is short:

foreach ($entry in $Sources) {
    $type, $value = $entry -split ':', 2
    try {
        $result = switch ($type.ToLowerInvariant()) {
            'gravatar' { Get-GravatarPicture $value }
            'git'      { Get-GitPicture $value $DefaultName }
            'url'      { Get-UrlPicture $value }
            default    { throw "unknown source type '$type' (use gravatar:, git: or url:)" }
        }
    }
    catch { $result = ConvertTo-SourceResult Error $null "${type}: $($_.Exception.Message)" }

    if ($result.Status -eq 'NotFound') { continue }
    return $result       # Found or Error: either way, we are done.
}

Adding a new source type, such as a file share or an internal HR photo API, means writing one function that returns Found, NotFound or Error, and adding a line to that switch.

Rendering the eleven sizes

Whatever the source returns (any size, PNG or JPEG, possibly with transparency), the script turns it into the eleven JPEGs Windows expects:

  • Centre-crop to a square. Sign-in tiles are round or square, and stretching looks awful.
  • Fill white behind transparency. JPEG has no alpha channel, and transparent pixels would
    otherwise turn black.
  • Honour EXIF rotation. A photo taken on a phone is often stored sideways with a “rotate me”
    tag.
  • Resize with high-quality bicubic and save at JPEG quality 90.

This uses System.Drawing, which is built in on Windows under both Windows PowerShell 5.1 and PowerShell 7 (7.6 is the current LTS). It does not work on Linux or macOS. The script is a Windows tool anyway, so that’s fine.

The files are named after the picture’s own hash, for example 3f9a1c0b7d22-Image448.jpg. That gives two things for free:

  1. Nothing changes unless the picture changed. If the registry already points at files with
    this hash, the script reports Unchanged and touches nothing. That makes it safe to run on a
    schedule.
  2. No stale cache. A new picture gets new file names, so nothing keeps showing the old one.

Running the privileged part as SYSTEM

Everything up to this point runs as you: resolving the user’s SID, downloading, verifying and rendering. Only the final step, copying files and writing the registry (and disabling the tasks if needed), runs as SYSTEM.

If you are not SYSTEM already, the script:

  1. writes the rendered JPEGs and a small job.json into a fresh folder under C:\Windows\Temp,
    which only administrators and SYSTEM can read;
  2. registers a temporary scheduled task that runs as SYSTEM, starts it, and waits for a
    result.json;
  3. reports the result, then unregisters the task and deletes the folder.

You don’t need PsExec or anything else to install. The task runs Windows PowerShell 5.1 even if you started the script in pwsh 7, because PowerShell 7 installed from winget is an MSIX package and SYSTEM can’t launch MSIX apps. The script is written to work in both, so this costs nothing.

If you already run as SYSTEM, from an RMM tool, Intune, or a startup script, the script skips the scheduled task and does the work directly. In that case pass -UserName, because SYSTEM has no picture of its own.

Walkthrough: a standalone PC

This covers a local account, or a Microsoft account on a PC that isn’t joined to anything.

1. Save Set-AccountPicture.ps1 somewhere, for example C:\Scripts.

2. Open PowerShell as administrator. If scripts are blocked, allow this one for the session:

Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass

3. Preview first. -WhatIf downloads and checks the sources but changes nothing:

C:\Scripts\Set-AccountPicture.ps1 -UserName jane -WhatIf -Verbose `
    -Source 'gravatar:jane@example.com', 'url:https://example.com/pictures/default.png'

-Verbose shows each source’s answer, for example gravatar:jane@example.com -> NotFound: no Gravatar for jane@example.com.

4. Run it for real by dropping -WhatIf. The script returns a short summary:

User             : DESKTOP-ABC123\jane
Sid              : S-1-5-21-…-1001
Source           : url:https://example.com/pictures/default.png
PictureSyncTasks : Leave
Result           : Updated

5. Sign out, or lock the screen, and look at the tile.

-UserName accepts jane, COMPUTER\jane, DOMAIN\jane, a Microsoft account’s local name, or a raw SID. Leave it out and the script uses whoever is running it. On a standalone PC that’s usually not what you want, because you’re running elevated, often as a different admin account.

Microsoft accounts: [to be confirmed in testing: whether Windows syncs the picture from the Microsoft account and overwrites the local one.]

Walkthrough: a domain-joined PC

The command is the same, with a domain user:

C:\Scripts\Set-AccountPicture.ps1 -UserName 'CONTOSO\jdoe' -Verbose `
    -Source 'gravatar:jdoe@contoso.com',
            'git:https://raw.githubusercontent.com/example/avatars/main/manifest.json'

Two things differ from the standalone case:

  • The SID is looked up against the domain, so the PC needs to reach a domain controller (or
    hold cached credentials for that user). Users who have never signed in to this PC still work:
    the registry key is per-SID, not per-profile.
  • The picture-sync tasks. -DisablePictureSyncTasks defaults to Auto, which runs
    dsregcmd /status. If the PC reports any domain join, Entra join or workplace registration, it
    disables UpdateUserPictureTask and UpdateUserPictureTaskContained as SYSTEM and verifies
    that they’re actually Disabled. Use Always or Never to override the check.

The summary then shows PictureSyncTasks : Disable the first time, and Leave after that, because the tasks are already off.

Undoing it

C:\Scripts\Set-AccountPicture.ps1 -UserName 'CONTOSO\jdoe' -Undo

This removes the user’s AccountPicture\Users\<SID> key and the C:\Users\Public\AccountPictures\<SID> folder, which puts them back to the default silhouette. The picture-sync tasks are machine-wide, so -Undo leaves them alone unless you ask for them back:

C:\Scripts\Set-AccountPicture.ps1 -UserName 'CONTOSO\jdoe' -Undo -RestorePictureSyncTasks

Only add -RestorePictureSyncTasks once nobody else on the PC still needs a custom picture. Otherwise the badge will be back by the next sign-in.

Scaling up

This post is about one PC at a time. If you get to the point of doing this across a domain, the shape changes. You run one central job that resolves each user’s picture through the same kind of chain, writes AD thumbnailPhoto (so Outlook and Teams agree with the sign-in screen), and drops the pre-rendered JPEGs on a read-only share. A GPO then copies them onto each PC with Group Policy Preferences Files and Registry items keyed on %LogonUserSid%. If that GPO is linked to computer OUs rather than user OUs, enable loopback processing. Disabling the picture-sync tasks still has to happen as SYSTEM, for the reasons above, for example from a startup script running this one with -DisablePictureSyncTasks Always.

Gotchas

  • Windows PowerShell 5.1 and TLS. Older 5.1 installs can still default to TLS 1.0/1.1, which
    Gravatar and GitHub refuse. The script forces TLS 1.2 on 5.1 only. PowerShell 7 negotiates
    properly by itself and shouldn’t have a protocol pinned.
  • Run it as often as you like. Files are named by hash, so an unchanged picture means
    Result : Unchanged and no writes.
  • The tile may not change until sign-out. LogonUI caches the picture, so lock the screen or
    sign out to see the new one.
  • Errors never wipe a picture. If you see Stopped without changing anything, read the
    reason. It is usually a typo in a URL or a manifest hash that is out of date.

The script

The full script is about 500 lines, mostly comments and error handling. It needs no modules and runs on Windows PowerShell 5.1 or PowerShell 7.

Download: Set-AccountPicture.ps1

Get-Help .\Set-AccountPicture.ps1 -Full

shows every parameter and example.