Skip to content

Avatar and image management

Arbitex provides a full avatar management pipeline for user profile images. This guide explains how avatars are validated, processed, stored, and served — including how identity provider (IdP) photos from SCIM and OIDC sources are synchronized and how administrators control avatar state.


Every Arbitex user account can have a profile avatar. Avatars appear in the following locations:

  • User profile page — displayed next to the user’s display name and email
  • Chat interface — shown alongside messages in conversation threads
  • Admin user list — visible in the Users panel for quick identification
  • Audit log entries — associated with events attributed to a user

Arbitex stores avatars using a content-addressed strategy: each image file is named by the SHA-256 hash of its binary content. This means identical images share a single stored file, URLs are permanently stable (the content never changes at a given URL), and cache headers can safely be set to immutable.

Avatars are always stored and served as WebP, regardless of the original upload format. This keeps file sizes small and serving consistent.


When a user uploads a new avatar, the image passes through a four-stage pipeline before being stored.

[User uploads file]
|
v
[1. Validate] ── reject if invalid format, too large, or malformed
|
v
[2. Resize] ── scale to fit 256×256px, center-crop if non-square
|
v
[3. Compress] ── convert to WebP at quality=80
|
v
[4. Store] ── write {sha256(bytes)}.webp to AVATAR_STORAGE_PATH
|
v
[Update user record with new avatar hash]

The server checks the uploaded file before any processing occurs:

Check Constraint Rejection reason
File format JPEG, PNG, GIF, or WebP only invalid_format
File size Maximum 5 MB (configurable via AVATAR_MAX_SIZE_MB) file_too_large
Image dimensions Must be at least 16×16 px image_too_small
Parseable image Must decode as a valid image corrupt_image

If any check fails, the server returns 400 Bad Request with a machine-readable error_code field and a human-readable message. The upload is rejected before any resize or storage operation takes place.

{
"error": "validation_failed",
"error_code": "file_too_large",
"message": "Avatar file exceeds the maximum allowed size of 5 MB."
}

After validation passes, the image is resized to fit within the configured maximum dimension (default: 256×256 px).

Scaling rules:

  • If the image is smaller than 256×256, it is upscaled to exactly 256×256.
  • If the image is already square and larger than 256×256, it is downscaled to 256×256.
  • If the image is non-square (e.g., 800×400), it is center-cropped to a square before being scaled to 256×256.

Center-crop behavior takes the largest centered square region from the image. For a 1200×600 image, this means a 600×600 region is cropped from the horizontal center, then scaled to 256×256.

The resized image is converted to WebP format at quality=80. This produces files typically in the 5–15 KB range for photographic content and 2–8 KB for illustrations or flat graphics.

WebP is used because:

  • It is widely supported by all modern browsers and HTTP clients
  • It produces smaller files than JPEG at equivalent visual quality for avatars
  • It provides a consistent format for serving, eliminating format negotiation

The quality setting (80) balances perceptual quality with file size. At 256×256 pixels, quality=80 is visually lossless for typical avatar content.

Once compression completes, the server computes the SHA-256 hash of the compressed WebP bytes and writes the file to:

{AVATAR_STORAGE_PATH}/{sha256_hex}.webp

If a file with that hash already exists (because the same image was uploaded before, or by another user), the write is skipped — the existing file is reused. The user’s record is updated to point to the new hash regardless.

After the file is stored, the user’s avatar_hash field is updated and avatar_source is set to "upload".


Content-addressed storage assigns each avatar a filename derived from the SHA-256 hash of its binary content, rather than from a user ID, timestamp, or sequential counter.

  1. The compressed WebP bytes are hashed using SHA-256.
  2. The hex digest (64 characters) becomes the filename.
  3. The file is stored at {AVATAR_STORAGE_PATH}/{hex}.webp.

Example filename:

a3f8c2e1d4b5609f3a87c2e1d4b56092e1d4b560a3f8c2e1d4b5609f3a87c2e.webp

The avatar URL exposed to clients follows the same structure:

/api/avatars/a3f8c2e1d4b5609f3a87c2e1d4b56092e1d4b560a3f8c2e1d4b5609f3a87c2e.webp

Immutable URLs. Because the filename is derived from the content, the file at a given URL never changes. A browser or CDN that caches the response can hold it indefinitely — the Cache-Control: immutable directive is safe to apply.

Automatic deduplication. Two users uploading identical images produce the same hash and share one file on disk. No additional deduplication logic is required.

No filename collisions. A user replacing their avatar generates a different hash (new content) and therefore a new filename. The old file remains on disk until the orphan cleanup job removes it. There is no possibility of one user’s upload overwriting another’s.

Tamper evidence. If a stored file is modified on disk, its content will no longer match the hash in its filename. Arbitex does not currently verify hashes on read, but the naming convention supports future integrity checks.


Avatars are served by the platform API over a stable URL pattern.

GET /api/avatars/{hash}.webp

Path parameter:

Parameter Description
hash The 64-character lowercase hex SHA-256 digest of the WebP content

Example request:

GET /api/avatars/a3f8c2e1d4b5609f3a87c2e1d4b56092e1d4b560a3f8c2e1d4b5609f3a87c2e.webp

Success response (200 OK):

HTTP/1.1 200 OK
Content-Type: image/webp
Cache-Control: public, max-age=31536000, immutable
Content-Length: 8432
ETag: "a3f8c2e1d4b5609f3a87c2e1d4b56092e1d4b560a3f8c2e1d4b5609f3a87c2e"
[WebP image bytes]

Not found response (404):

HTTP/1.1 404 Not Found
Content-Type: application/json
{
"error": "avatar_not_found",
"message": "No avatar found for the specified hash."
}

A 404 is returned when the hash does not exist in storage. This occurs if the file was removed by the orphan cleanup job after the user’s record was deleted, or if the hash is malformed.

The response includes Cache-Control: public, max-age=31536000, immutable. This instructs clients and intermediate caches to store the response for one year without revalidation. Because content-addressed filenames guarantee that the file at a given URL never changes, this is always safe.

Avatar URLs are publicly accessible without authentication. This is intentional — avatars are non-sensitive profile images and restricting them would require all API clients and chat UIs to handle authenticated image requests. If your deployment requires access control on avatars, place the platform behind a reverse proxy that adds authentication at the edge.

Arbitex avatar URLs are CDN-friendly by design:

  • Origin pull: Configure your CDN to pull from /api/avatars/ on the platform origin. The Cache-Control: immutable header ensures the CDN caches aggressively.
  • Edge caching: Once cached at the edge, avatar requests do not reach the origin. This reduces load on the platform for high-traffic deployments.
  • Cache invalidation: Because filenames change when content changes, you do not need to invalidate CDN cache entries when a user updates their avatar. The old URL becomes unused; the new URL is served fresh on first request.
  • CORS: If your CDN or frontend origin differs from the platform API origin, ensure the platform’s CORS configuration allows GET requests from your frontend domains.

Arbitex can automatically populate user avatars from identity provider sources. Photos sourced from the IdP pass through the same validate → resize → compress → store pipeline as direct uploads.

When SCIM provisioning is configured, the SCIM User schema includes a photos attribute. If your IdP populates this attribute, Arbitex downloads and processes the photo during user creation and on subsequent SCIM update events.

SCIM User schema excerpt:

{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "user-uuid",
"userName": "[email protected]",
"displayName": "Alice Chen",
"photos": [
{
"value": "https://idp.example.com/photos/alice.jpg",
"type": "photo",
"primary": true
}
]
}

When Arbitex receives a SCIM POST /scim/v2/Users or PUT /scim/v2/Users/{id} request containing a photos array, it:

  1. Selects the entry with "primary": true, or the first entry if none is marked primary.
  2. Downloads the image from the URL in value.
  3. Runs the full validation, resize, and compression pipeline.
  4. Stores the result and sets avatar_source: "scim" on the user record.

If the download fails (network error, 4xx/5xx from the IdP), the error is logged and avatar sync is skipped for that event. The SCIM provisioning response still returns success — avatar sync failure does not fail the provisioning operation.

When OIDC SSO is configured, the ID token and userinfo endpoint may include a picture claim containing a URL to the user’s photo.

ID token excerpt:

{
"sub": "user-uuid",
"email": "[email protected]",
"name": "Alice Chen",
"picture": "https://lh3.googleusercontent.com/a/photo-url"
}

On first login via OIDC, Arbitex downloads and processes the picture URL using the same pipeline. On subsequent logins, the picture URL is checked against the stored value — if it has changed, the new photo is downloaded and processed.

avatar_source is set to "oidc" when a photo is successfully synced from the OIDC picture claim.

Every user record includes an avatar_source field that tracks where the current avatar originated.

Value Description
"upload" The user uploaded their own image directly
"scim" The avatar was synced from the SCIM photos attribute
"oidc" The avatar was synced from the OIDC picture claim
"default" No avatar is set; the UI renders a system-generated initials placeholder

The avatar_source field appears in the user detail response from the admin API:

{
"id": "user-uuid",
"email": "[email protected]",
"display_name": "Alice Chen",
"avatar_hash": "a3f8c2e1d4b5609f3a87c2e1d4b56092e1d4b560a3f8c2e1d4b5609f3a87c2e",
"avatar_source": "oidc",
"created_at": "2025-11-01T09:00:00Z"
}

When avatar_source is "default", avatar_hash is null.

When multiple avatar sources are available, Arbitex applies the following precedence:

user upload > IdP photo (SCIM or OIDC) > default

User upload takes highest precedence. Once a user uploads their own avatar, avatar_source changes to "upload". Subsequent SCIM updates and OIDC logins do not overwrite the user-uploaded avatar, even if the IdP photo changes.

IdP photo takes precedence over default. If no user upload exists, any available SCIM or OIDC photo is used. If both SCIM and OIDC photos are available, SCIM takes precedence (SCIM provisioning events are processed independently of login, and SCIM is considered the authoritative directory source).

Default is the fallback. If neither a user upload nor an IdP photo is available, the UI renders a placeholder based on the user’s initials. No file is stored; avatar_hash is null.

An administrator can reset a user’s avatar from "upload" back to IdP or default using the admin DELETE endpoint described in the next section. This re-enables IdP photo sync for the user.


Administrators manage avatar state through the admin API. The Cloud Portal admin panel surfaces these controls in the user detail view.

DELETE /api/v1/admin/users/{id}/avatar
Authorization: Bearer <admin-token>

Path parameter:

Parameter Description
id The UUID of the target user

Success response (204 No Content):

The user’s avatar_hash is cleared and avatar_source reverts according to the following logic:

  • If the user has a SCIM photo URL on record, avatar sync is triggered immediately and avatar_source becomes "scim".
  • If no SCIM photo is available but an OIDC picture claim was recorded at last login, avatar sync is triggered and avatar_source becomes "oidc".
  • If neither IdP source is available, avatar_hash is set to null and avatar_source becomes "default".

Error responses:

Status Condition
404 Not Found No user exists with the given ID
409 Conflict The user’s current avatar_source is not "upload" — there is no custom avatar to remove

Example:

Terminal window
curl -s -X DELETE \
"https://platform.example.com/api/v1/admin/users/user-uuid/avatar" \
-H "Authorization: Bearer ${ADMIN_TOKEN}"

The user detail page in the Cloud Portal (Admin → Users → select user) shows:

  • Avatar preview — the current avatar image, or the initials placeholder
  • Avatar source — one of upload, scim, oidc, or default
  • Avatar hash — the hex digest (truncated to 12 characters with a copy button for the full value)
  • Remove avatar button — visible only when avatar_source is "upload"

Clicking Remove avatar calls the DELETE endpoint and refreshes the panel. If an IdP photo is available, the new IdP-sourced avatar appears immediately.

Bulk avatar removal is not supported. The DELETE endpoint is per-user only. If you need to reset avatars for a large number of users (for example, following an IdP migration), use the admin API in a loop:

Terminal window
# Reset avatars for a list of user IDs
while IFS= read -r user_id; do
curl -s -X DELETE \
"https://platform.example.com/api/v1/admin/users/${user_id}/avatar" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o /dev/null -w "${user_id}: %{http_code}\n"
done < user_ids.txt

An orphaned avatar file is a WebP on disk whose hash is no longer referenced by any user record. Orphans are created when:

  • A user deletes their account (user record removed, file remains)
  • A user uploads a new avatar (old hash removed from user record, old file remains)
  • An admin removes a custom avatar that had no IdP fallback (old hash cleared, old file remains)

Because avatar files are small (typically 5–15 KB each), orphans accumulate slowly. However, in deployments with high user churn or frequent avatar updates, orphans can build up over time.

Arbitex runs a scheduled orphan cleanup task at the interval configured by AVATAR_CLEANUP_INTERVAL (default: 24 hours). The job:

  1. Lists all .webp files in AVATAR_STORAGE_PATH.
  2. Queries the database for all non-null avatar_hash values across all user records.
  3. Computes the set difference: files on disk that have no corresponding hash in the database.
  4. Deletes each orphaned file.
  5. Logs a summary of the cleanup run.

Example log output:

[avatar-cleanup] Starting orphan cleanup scan
[avatar-cleanup] Found 1,247 files on disk
[avatar-cleanup] Found 1,183 referenced hashes in database
[avatar-cleanup] Identified 64 orphaned files
[avatar-cleanup] Deleted 64 orphaned files (total: 892 KB reclaimed)
[avatar-cleanup] Cleanup completed in 0.42s

The cleanup job only deletes files that match all of the following conditions:

  • The file extension is .webp (non-WebP files in the storage directory are never touched)
  • The filename (without extension) matches the expected 64-character hex pattern
  • The filename does not appear in the set of active avatar_hash values from the database

The query for active hashes is performed immediately before the delete pass. Files created between the scan and the delete pass (new uploads during the cleanup window) are safe — they are referenced in the database by the time the delete pass runs.

Set AVATAR_CLEANUP_INTERVAL in your environment configuration. The value is a duration string:

# Run cleanup every 12 hours
AVATAR_CLEANUP_INTERVAL: 12h
# Run cleanup every 6 hours (high-churn deployments)
AVATAR_CLEANUP_INTERVAL: 6h
# Disable cleanup (not recommended for production)
AVATAR_CLEANUP_INTERVAL: 0

Setting the interval to 0 disables the scheduled job entirely. If you disable automatic cleanup, you are responsible for periodically removing orphaned files to prevent unbounded storage growth.


Avatar files are stored on a persistent volume. The default storage path inside the container is /data/avatars/. This directory must survive container restarts and, in multi-replica deployments, must be shared across all replica instances.

Mount a named volume at the default storage path:

docker-compose.yml
services:
platform:
image: ghcr.io/arbitex/platform:latest
environment:
AVATAR_STORAGE_PATH: /data/avatars
volumes:
- avatar_data:/data/avatars
# ... other configuration
volumes:
avatar_data:
driver: local

For a custom storage path:

services:
platform:
image: ghcr.io/arbitex/platform:latest
environment:
AVATAR_STORAGE_PATH: /opt/arbitex/avatars
volumes:
- avatar_data:/opt/arbitex/avatars
volumes:
avatar_data:
driver: local

For Kubernetes deployments, create a PVC and mount it into the platform pod:

avatar-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: arbitex-avatars
namespace: arbitex
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: standard
# platform-deployment.yaml (excerpt)
apiVersion: apps/v1
kind: Deployment
metadata:
name: arbitex-platform
namespace: arbitex
spec:
template:
spec:
containers:
- name: platform
image: ghcr.io/arbitex/platform:latest
env:
- name: AVATAR_STORAGE_PATH
value: /data/avatars
volumeMounts:
- name: avatar-storage
mountPath: /data/avatars
volumes:
- name: avatar-storage
persistentVolumeClaim:
claimName: arbitex-avatars

The platform container runs as a non-root user. The storage directory must be writable by the container’s UID. If you are using a pre-existing volume or host mount, set permissions before starting the container:

Terminal window
# Set ownership to UID 1000 (platform container user)
chown -R 1000:1000 /path/to/avatar/storage
chmod -R 750 /path/to/avatar/storage

If using a Kubernetes init container:

initContainers:
- name: volume-permissions
image: busybox:1.36
command: ["sh", "-c", "chown -R 1000:1000 /data/avatars && chmod -R 750 /data/avatars"]
volumeMounts:
- name: avatar-storage
mountPath: /data/avatars

Single-node ReadWriteOnce volumes are not suitable for deployments with more than one platform replica, because only one pod can mount the volume at a time. For multi-replica configurations, use shared network storage:

Storage type Kubernetes access mode Notes
NFS ReadWriteMany Supported; ensure NFS server is highly available
Azure Files ReadWriteMany Supported via Azure CSI driver
AWS EFS ReadWriteMany Supported via EFS CSI driver
GCP Filestore ReadWriteMany Supported via Filestore CSI driver
S3 / Blob storage N/A Not currently supported as a native backend

S3-compatible object storage as a native avatar backend is planned for a future release. Until then, shared filesystem storage is the recommended approach for multi-replica Kubernetes deployments.

Avatar storage volumes are small relative to other platform data. A deployment with 1,000 users with avatars will typically have 5–15 MB of avatar data on disk (after deduplication). Even at 100,000 users, the volume is unlikely to exceed a few hundred megabytes.

Include the avatar volume in your backup rotation alongside the database:

Terminal window
# Backup avatar storage to a tarball
tar -czf avatars-backup-$(date +%Y%m%d).tar.gz -C /data/avatars .
# Or rsync to a backup location
rsync -av --delete /data/avatars/ backup-host:/backups/avatars/

Because avatar filenames are content-addressed and immutable, incremental backups (rsync, deduplicating backup tools) work efficiently — unchanged files are never re-copied.


All avatar-related configuration is provided as environment variables. Set these in your Docker Compose environment block, Kubernetes ConfigMap, or deployment secrets manager.

Variable Type Default Description
AVATAR_STORAGE_PATH string /data/avatars Absolute filesystem path where WebP avatar files are stored
AVATAR_MAX_SIZE_MB integer 5 Maximum upload size in megabytes. Uploads exceeding this limit are rejected during validation.
AVATAR_MAX_DIMENSION integer 256 Maximum width and height in pixels. Images larger than this are resized before compression.
AVATAR_WEBP_QUALITY integer 80 WebP compression quality, 1–100. Higher values produce larger files with better visual quality.
AVATAR_CLEANUP_INTERVAL duration 24h How often the orphan cleanup job runs. Set to 0 to disable. Accepts Go duration strings: 1h, 30m, 12h.
AVATAR_IDP_SYNC_ENABLED boolean true Enable or disable IdP photo sync from SCIM and OIDC sources. When false, user photos attributes and picture claims are ignored.

Example configuration block (Docker Compose):

environment:
AVATAR_STORAGE_PATH: /data/avatars
AVATAR_MAX_SIZE_MB: "5"
AVATAR_MAX_DIMENSION: "256"
AVATAR_WEBP_QUALITY: "80"
AVATAR_CLEANUP_INTERVAL: 24h
AVATAR_IDP_SYNC_ENABLED: "true"

Example ConfigMap (Kubernetes):

apiVersion: v1
kind: ConfigMap
metadata:
name: arbitex-platform-config
namespace: arbitex
data:
AVATAR_STORAGE_PATH: /data/avatars
AVATAR_MAX_SIZE_MB: "5"
AVATAR_MAX_DIMENSION: "256"
AVATAR_WEBP_QUALITY: "80"
AVATAR_CLEANUP_INTERVAL: 24h
AVATAR_IDP_SYNC_ENABLED: "true"

Symptom: The user uploads a new avatar through the UI, the upload appears to succeed, but the old avatar continues to appear.

Likely causes and remedies:

  1. Browser cache. The old avatar URL may be cached by the browser. Because avatar URLs carry Cache-Control: immutable, the browser will not re-request the old URL. The new avatar has a different URL (different hash). Check whether the new avatar URL is being requested in the browser’s network panel. A hard refresh (Ctrl+Shift+R) will not help — the URL must change for the new image to load.

  2. CDN serving stale content. If a CDN is in front of the platform, verify that the new avatar URL reaches the CDN. Because filenames change on upload, the CDN should not have a cached entry for the new URL. If the old URL is still being used in the UI, the issue is in the frontend — the avatar URL is not being refreshed after upload.

  3. Upload validation failure not surfaced. The upload may have been rejected silently. Check the platform API logs for validation_failed or file_too_large events associated with the user’s ID and the time of the upload attempt.

  4. Storage write failure. Check that AVATAR_STORAGE_PATH is writable by the container user and that the volume has sufficient free space.

Symptom: A user’s SCIM or OIDC photo is not appearing as their avatar.

Check for SCIM:

  • Confirm that the SCIM User object sent by your IdP includes a photos array with a primary: true entry.
  • Check the platform logs for SCIM update events for the user. Look for avatar_sync_failed log entries that include a download URL and HTTP status code.
  • Confirm that the platform host can reach the photo URL. SCIM photo URLs must be publicly accessible or reachable from the platform’s network.
  • Verify that AVATAR_IDP_SYNC_ENABLED is true.

Check for OIDC:

  • Confirm that the ID token or userinfo response from your IdP includes a picture claim.
  • For Google Workspace: the picture claim is present by default when the profile OAuth scope is requested.
  • For Microsoft Entra ID: the picture claim is not included by default. Entra ID requires additional configuration to return a photo URL in the OIDC flow; check your Entra ID app manifest.
  • Check the platform logs for oidc_avatar_sync_failed entries at login time.
  • Confirm that AVATAR_IDP_SYNC_ENABLED is true.

If the user has a custom upload: A user’s own upload (avatar_source: "upload") takes precedence over IdP photos. If the user has previously uploaded an avatar, IdP sync will not overwrite it. An admin must use the DELETE endpoint to reset avatar_source before IdP sync will apply.

Symptom: The avatar storage volume is larger than expected given the number of users.

Likely causes:

  1. Orphan cleanup is disabled or not running. Check that AVATAR_CLEANUP_INTERVAL is not set to 0. Check platform logs for [avatar-cleanup] entries to confirm the job is running on schedule.

  2. High avatar churn. If users frequently update their avatars, each update leaves behind an orphaned file until the next cleanup run. Reduce AVATAR_CLEANUP_INTERVAL to run cleanup more frequently.

  3. Non-avatar files in the storage directory. The cleanup job only removes files matching the 64-character hex .webp pattern. If other files have accumulated in AVATAR_STORAGE_PATH, they will not be removed automatically. Inspect the directory and remove unexpected files manually.

To immediately inspect storage usage:

Terminal window
# Count files and total size
find /data/avatars -name "*.webp" | wc -l
du -sh /data/avatars/

Symptom: A request to /api/avatars/{hash}.webp returns 404, but the user’s record shows a non-null avatar_hash.

Likely causes:

  1. File was cleaned up after the user record was deleted. If the user was recently deleted and the orphan cleanup ran, the file may have been removed. This is expected behavior. Re-upload is not possible for a deleted user.

  2. Storage volume not mounted. If the platform container was restarted without the persistent volume, AVATAR_STORAGE_PATH may be an empty ephemeral directory. Verify the volume mount is configured and the directory contains the expected files.

  3. Hash mismatch. If files in storage were renamed or moved, the hash in the user record no longer matches a filename on disk. Do not rename avatar files manually. Restore from backup if files have been reorganized.

  4. Wrong AVATAR_STORAGE_PATH. If the platform was restarted with a different AVATAR_STORAGE_PATH than the one used when the file was written, the platform is looking in the wrong directory. Ensure the environment variable is consistent across restarts and replicas.

To verify a specific file’s presence on disk:

Terminal window
HASH="a3f8c2e1d4b5609f3a87c2e1d4b56092e1d4b560a3f8c2e1d4b5609f3a87c2e"
ls -lh "/data/avatars/${HASH}.webp"

If the file is missing and the user account is active, re-uploading an avatar resolves the issue immediately.