Synchronize Microsoft EntraID user photos to macOS

Here’s how I use Workspace ONE UEM to synchronize Microsoft EntraID user profile photos with the local macOS account. Shortly after working on this I learned that Apple Platform SSO for macOS 26 introduced a new key to do this for you, if the Identity Provider updates their app to support it. In the meantime here’s one method to get this working immediately.

Login to https://entra.microsoft.com and select “App registrations” from the left-hand blade.

Select “New Registration” and give the app a name.

Navigate to the “Certificates & Secrets” section of the App registration and generate a new Client Secret. Copy those values for use later on.

Select the “API Permissions” where the permissions default to “User.Read.” For this to work add the permission “User.Read.All”

After adding the “User.Read.All” permission, select the option above the table to “Grant admin consent.”

Open the Workspace ONE UEM Console and navigate to Resources > Scripting > Scripts.

Add a New macOS Script.

Give the new script a name and select Next

On the Details page set the following:

Language = ZSH
Execution Context = System
Timeout to 120

And copy and paste the script below:

#!/bin/zsh
# Sync Microsoft Entra ID user photo with the macOS login picture
# This script is based on Scott E Kendall's script which he wrote for JAMF
# It's purpose is to sync the photo stored in Microsoft EntraID with the macOS local user account photo
# How you get the photos in EntraID in the first place is beyond the scope of this script
# which can be found at https://github.com/ScottEKendall/JAMF-Pro-System-Scripts/blob/main/Maintenance%20-%20inTune%20-%20SyncEntraPic.sh
# By removing the JAMF specific features and tweaking a number of other parts to the script
# I have modified this version to be run as a Workspace ONE UEM Script Object

# DEBUG=1 for verbose tracing

# --- Logging (keep this at the very top) ---
exec >> /var/tmp/profilephotosync.log 2>&1
MAX_LOG=524288 # 512 KB
LOG=/var/tmp/profilephotosync.log
if [[ -f "$LOG" && $(/usr/bin/stat -f%z "$LOG") -gt $MAX_LOG ]]; then
  /bin/mv -f "$LOG" "${LOG}.1" 2>/dev/null || true
  : > "$LOG"
fi

set -euo pipefail
[[ -n "${DEBUG:-}" ]] && set -x

# ---------- Expect these from UEM Variables ----------
CLIENT_ID="${CLIENT_ID:?Missing CLIENT_ID}"
CLIENT_SECRET="${CLIENT_SECRET:?Missing CLIENT_SECRET}"
TENANT_ID="${TENANT_ID:?Missing TENANT_ID}"
UPN_PARAM="${UPN_PARAM:-}"                 # e.g., {UserPrincipalName}
DEFAULT_DOMAIN="${DEFAULT_DOMAIN:-}"       # optional if UPN_PARAM already has '@'

# ---------- Console user ----------
LOGGED_IN_USER=$(/usr/bin/stat -f%Su /dev/console)

# ---------- Paths / temps ----------
PHOTO_DIR="/Users/${LOGGED_IN_USER}/Library/Application Support"
PERM_PHOTO_DIR="/Library/User Pictures"
TMP_FILE_STORAGE="$(/usr/bin/mktemp /var/tmp/EntraPhoto.XXXXX)"
/bin/chmod 600 "${TMP_FILE_STORAGE}"

TOKEN_RESPONSE_FILE="/var/tmp/profilephotosync.token.json"

# ---------- Helpers ----------
cleanup_and_exit() {
  local code="${1:-0}"
  [[ -f "${TMP_FILE_STORAGE}" ]] && /bin/rm -f "${TMP_FILE_STORAGE}"
  exit "${code}"
}

say_startup() {
  echo "INFO: LOGGED_IN_USER='${LOGGED_IN_USER:-<empty>}'"
  echo "INFO: TENANT_ID='${TENANT_ID:-<unset>}'"
  echo "INFO: CLIENT_ID='${CLIENT_ID:-<unset>}'"
  echo "INFO: UPN='${UPN_PARAM:-<unset>}'"
  if [[ -n "${CLIENT_SECRET:-}" ]]; then
    echo "INFO: CLIENT_SECRET is set (hidden)"
  else
    echo "INFO: CLIENT_SECRET is <unset>"
  fi
}

check_logged_in_user() {
  if [[ -z "${LOGGED_IN_USER:-}" || "${LOGGED_IN_USER}" == "root" ]]; then
    echo "INFO: No GUI user at /dev/console (current: '${LOGGED_IN_USER:-<empty>}'). Exiting."
    cleanup_and_exit 0
  fi
}

require_params() {
  local miss=0
  [[ -z "${CLIENT_ID:-}" ]] && { echo "ERROR: Missing CLIENT_ID"; miss=1; }
  [[ -z "${CLIENT_SECRET:-}" ]] && { echo "ERROR: Missing CLIENT_SECRET"; miss=1; }
  [[ -z "${TENANT_ID:-}" ]] && { echo "ERROR: Missing TENANT_ID"; miss=1; }
  if [[ $miss -ne 0 ]]; then
    echo "ERROR: Required variables missing; exiting."
    cleanup_and_exit 1
  fi
}

resolve_upn() {
  if [[ -n "${UPN_PARAM:-}" ]]; then
    MS_USER_NAME="${UPN_PARAM}"
  elif [[ "${LOGGED_IN_USER}" == *"@"* ]]; then
    MS_USER_NAME="${LOGGED_IN_USER}"
  elif [[ -n "${DEFAULT_DOMAIN:-}" ]]; then
    MS_USER_NAME="${LOGGED_IN_USER}@${DEFAULT_DOMAIN}"
  else
    echo "ERROR: Cannot resolve a UPN. Provide UPN_PARAM or DEFAULT_DOMAIN."
    cleanup_and_exit 1
  fi
}

# ---------- Graph token (validated) ----------
msgraph_get_access_token() {
  echo "INFO: Requesting Graph token..."
  /usr/bin/curl -s -X POST "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    --data-urlencode "grant_type=client_credentials" \
    --data-urlencode "client_id=${CLIENT_ID}" \
    --data-urlencode "client_secret=${CLIENT_SECRET}" \
    --data-urlencode "scope=https://graph.microsoft.com/.default" \
    > "${TOKEN_RESPONSE_FILE}"

  if command -v jq >/dev/null 2>&1; then
    MS_ACCESS_TOKEN=$(jq -r '.access_token // empty' < "${TOKEN_RESPONSE_FILE}")
  elif command -v python3 >/dev/null 2>&1; then
    MS_ACCESS_TOKEN=$(/usr/bin/python3 - <<'PY'
import json
print(json.load(open("/var/tmp/profilephotosync.token.json")).get("access_token",""))
PY
)
  else
    MS_ACCESS_TOKEN=$(grep -o '"access_token":"[^"]*' "${TOKEN_RESPONSE_FILE}" | /usr/bin/cut -d'"' -f4)
  fi

  local tokenlen=${#MS_ACCESS_TOKEN}
  local dotcount; dotcount=$(printf %s "${MS_ACCESS_TOKEN}" | /usr/bin/awk -F'.' '{print NF-1}')
  if [[ -z "${MS_ACCESS_TOKEN}" || "${dotcount}" -lt 2 ]]; then
    echo "ERROR: Access token missing or malformed (len=${tokenlen}, dots=${dotcount})."
    echo "DEBUG: Raw token response (first 400 chars):"
    /usr/bin/head -c 400 "${TOKEN_RESPONSE_FILE}"; echo
    echo "HINT: Verify CLIENT_ID/CLIENT_SECRET/TENANT_ID; ensure Graph 'User.Read.All' (Application) has Admin consent."
    cleanup_and_exit 1
  fi

  echo "INFO: Token acquired (len=${tokenlen}, dots=${dotcount})."
  echo "DEBUG: Authorization header sanity: len=${#MS_ACCESS_TOKEN}, dots=$(printf %s "${MS_ACCESS_TOKEN}" | awk -F'.' '{print NF-1}')"
}

# ---------- HTTP helper (publishes globals) ----------
graph_curl() {
  # graph_curl METHOD URL [OUTFILE]
  local method="$1"; shift
  local url="$1"; shift

  local use_outfile=0 outfile=""
  if (( $# >= 1 )); then
    use_outfile=1
    outfile="$1"
    shift
  fi

  local response
  if (( use_outfile == 1 )); then
    response=$(/usr/bin/curl -s -w "HTTPSTATUS:%{http_code}" -X "$method" \
      -H "Authorization: Bearer ${MS_ACCESS_TOKEN}" \
      -H "Accept: application/json" "$url" --output "$outfile")
  else
    response=$(/usr/bin/curl -s -w "HTTPSTATUS:%{http_code}" -X "$method" \
      -H "Authorization: Bearer ${MS_ACCESS_TOKEN}" \
      -H "Accept: application/json" "$url")
  fi

  typeset -g HTTP_BODY
  typeset -g HTTP_CODE
  HTTP_BODY=$(printf %s "$response" | sed -e 's/HTTPSTATUS\:.*//g')
  HTTP_CODE=$(printf %s "$response" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')

  echo "DEBUG: [${HTTP_CODE}] ${url}"
  [[ -n "$HTTP_BODY" ]] && echo "DEBUG Body: $HTTP_BODY" >&2

  if [[ "$HTTP_CODE" -ge 400 && $use_outfile -eq 1 ]]; then
    local err
    err=$(/usr/bin/curl -s -X "$method" \
      -H "Authorization: Bearer ${MS_ACCESS_TOKEN}" \
      -H "Accept: application/json" "$url")
    echo "DEBUG Body (replay): $err" >&2
  fi

  [[ "$HTTP_CODE" -ge 400 ]] && return 1 || return 0
}

# ---------- Resolve GUID (prints ONLY GUID to STDOUT) ----------
resolve_user_object_id() {
  local identifier="$1"
  local resp http_code body id=""

  # Direct /users/{identifier}
  resp=$(/usr/bin/curl -s -w "HTTPSTATUS:%{http_code}" \
    -H "Authorization: Bearer ${MS_ACCESS_TOKEN}" \
    -H "Accept: application/json" \
    "https://graph.microsoft.com/v1.0/users/${identifier}?\$select=id,userPrincipalName")

  http_code=$(printf %s "$resp" | sed -n 's/.*HTTPSTATUS:\([0-9][0-9][0-9]\)$/\1/p')
  body=$(printf %s "$resp" | sed 's/HTTPSTATUS:[0-9][0-9][0-9]$//')

  echo "DEBUG: [/users/{id-or-upn}] code=${http_code}" >&2
  [[ -n "$body" ]] && echo "DEBUG Body: $body" >&2

  if [[ "$http_code" == "200" ]]; then
    if command -v jq >/dev/null 2>&1; then
      id=$(printf %s "$body" | jq -r '.id // empty')
    else
      id=$(printf %s "$body" | awk -F\" '/"id"\s*:/ {print $4; exit}')
    fi
    [[ -n "$id" ]] && { echo "$id"; return 0; }
  fi

  # Fallback: encoded filter
  resp=$(/usr/bin/curl -s -w "HTTPSTATUS:%{http_code}" -G \
    -H "Authorization: Bearer ${MS_ACCESS_TOKEN}" \
    -H "Accept: application/json" \
    --data-urlencode "\$select=id,userPrincipalName,mail" \
    --data-urlencode "\$filter=userPrincipalName eq '${identifier}' or mail eq '${identifier}'" \
    "https://graph.microsoft.com/v1.0/users")

  http_code=$(printf %s "$resp" | sed -n 's/.*HTTPSTATUS:\([0-9][0-9][0-9]\)$/\1/p')
  body=$(printf %s "$resp" | sed 's/HTTPSTATUS:[0-9][0-9][0-9]$//')

  echo "DEBUG: [/users?filter] code=${http_code}" >&2
  [[ -n "$body" ]] && echo "DEBUG Body: $body" >&2

  if [[ "$http_code" == "200" ]]; then
    if command -v jq >/dev/null 2>&1; then
      id=$(printf %s "$body" | jq -r '.value[0].id // empty')
    else
      id=$(printf %s "$body" | awk -F\" '/"id"\s*:/ {print $4; exit}')
    fi
    [[ -n "$id" ]] && { echo "$id"; return 0; }
  fi

  return 1
}

# ---------- Photo helpers ----------
msgraph_get_user_photo_etag() {
  local url="https://graph.microsoft.com/v1.0/users/${USER_ID}/photo"
  if graph_curl GET "$url"; then
    printf %s "$HTTP_BODY" | /usr/bin/awk -F\" '/@odata.mediaEtag/ {print $4}'
  else
    echo "null"
  fi
}

msgraph_get_user_photo_jpeg_default() {
  local out="$1"
  local url="https://graph.microsoft.com/v1.0/users/${USER_ID}/photo/\$value"
  graph_curl GET "$url" "$out"
}

msgraph_get_user_photo_jpeg_by_size() {
  local out="$1"
  local sizes=("648x648" "504x504" "432x432" "360x360" "240x240" "120x120" "96x96" "64x64" "48x48")
  for sz in "${sizes[@]}"; do
    local url="https://graph.microsoft.com/v1.0/users/${USER_ID}/photos/${sz}/\$value"
    if graph_curl GET "$url" "$out"; then
      echo "INFO: Downloaded size variant: ${sz}"
      return 0
    fi
  done
  return 1
}

ensure_dir() { [[ -d "$1" ]] || /bin/mkdir -p "$1"; }

create_photo_dir_and_copy() {
  PERM_PHOTO_FILE="${PERM_PHOTO_DIR}/${LOGGED_IN_USER}.jpg"
  /bin/mkdir -p "${PERM_PHOTO_DIR}"
  /bin/cp "${PHOTO_FILE}" "${PERM_PHOTO_FILE}"
  /bin/chmod 644 "${PERM_PHOTO_FILE}"
}

set_profile_picture() {
  /usr/bin/sips -Z 128 "${PERM_PHOTO_FILE}" --out "${TMP_FILE_STORAGE}" >/dev/null 2>&1 || {
    echo "ERROR: Cannot resize image"; cleanup_and_exit 1; }
  if /usr/bin/dscl . -create "/Users/${LOGGED_IN_USER}" JPEGPhoto "$(/usr/bin/base64 < "${TMP_FILE_STORAGE}")"; then
    echo "SUCCESS: JPEGPhoto updated for ${LOGGED_IN_USER}"
  else
    echo "ERROR: Failed to set JPEGPhoto for ${LOGGED_IN_USER}"
  fi
  /usr/bin/dscl . -create "/Users/${LOGGED_IN_USER}" picture "${PERM_PHOTO_FILE}" || true
}

# ---------- Main ----------
say_startup
check_logged_in_user
require_params
resolve_upn
msgraph_get_access_token

echo "INFO: Using Graph identifier: ${MS_USER_NAME}"

# Resolve GUID and validate
USER_ID="$(resolve_user_object_id "${MS_USER_NAME}" | tr -d '\r\n')"
if ! [[ "$USER_ID" =~ ^[0-9a-fA-F-]{36}$ ]]; then
  echo "ERROR: Resolver did not return a valid GUID. Got: '${USER_ID}'"
  cleanup_and_exit 1
fi
echo "INFO: Using objectId: ${USER_ID}"

# Files keyed by GUID
ensure_dir "${PHOTO_DIR}"
SAFE_ID="${USER_ID//[^a-zA-Z0-9]/_}"
PHOTO_FILE="${PHOTO_DIR}/${SAFE_ID}.jpg"
ETAG_FILE="${PHOTO_DIR}/${SAFE_ID}.etag"

CURRENT_ETAG="$(msgraph_get_user_photo_etag || echo "null")"
[[ -z "${CURRENT_ETAG}" ]] && CURRENT_ETAG="null"

if [[ -f "${ETAG_FILE}" && "${CURRENT_ETAG}" != "null" ]]; then
  PREV_ETAG=$(cat "${ETAG_FILE}" || true)
  if [[ "${CURRENT_ETAG}" == "${PREV_ETAG}" ]]; then
    echo "INFO: Photo unchanged (ETag match). Exiting."
    cleanup_and_exit 0
  fi
fi

# Try default, then size variants
if ! msgraph_get_user_photo_jpeg_default "${PHOTO_FILE}"; then
  echo "INFO: Default endpoint failed; trying size-based variants…"
  if ! msgraph_get_user_photo_jpeg_by_size "${PHOTO_FILE}"; then
    echo "INFO: No photo could be downloaded for ${MS_USER_NAME} via any endpoint."
    echo "HINTS: Ensure Graph Application permission 'User.Read.All' (Application) has admin consent; secret valid; TENANT_ID correct."
    cleanup_and_exit 0
  fi
fi

create_photo_dir_and_copy
set_profile_picture

[[ "${CURRENT_ETAG}" != "null" ]] && echo "${CURRENT_ETAG}" > "${ETAG_FILE}"
echo "SUCCESS: Photo saved to ${PHOTO_FILE}"
cleanup_and_exit 0

Select Next to bring up the Variables page and define the following IN ORDER:

TENANT_ID = Microsoft EntraID Tenant ID visible in the Microsoft EntraID admin center on the Overview Blade

UPN_PARAM = {UserPrincipalName}

CLIENT_ID = the value from the EntraID App Registration

DEFAULT_DOMAIN = the domain to sync the users from

CLIENT_SECRET = the value from the EntraID App Registratin

Save the script and deploy it.

And now all of your users will have a local macOS profile photo that matches the profile photo published in Microsoft EntraID.

Bonus tip:

For populating the user photos here are two free options that work wonderfully:

CodeTwo Active Directory Photos:

https://www.codetwo.com/freeware/active-directory-photos

CodeTwo User Photos for Office 365

https://www.codetwo.com/freeware/user-photos-for-office-365/