Skip to main content

Concepts

The identity key

The cross-app key is the email, hashed:

hash = sha256( lower( trim( email ) ) ) // 64 hex chars — byte-identical to Gravatar's spec
  • Email is the only identifier stable across WorkOS environments. The WorkOS user id differs per environment/app, so it can't be the shared key.
  • This is what makes adoption free. An app already knows its user's email, so it computes the hash itself and builds the URL. It never calls a Portrait API, holds a Portrait secret, or stores a Portrait id.
  • Portrait computes the same hash from the WorkOS email at login. Both sides independently arrive at the same key — that's the join, and it needs no coordination.
  • The hash is not a secret. It's a public identifier, safe in client-side HTML. An unknown hash returns a generated avatar, so it's no email-existence oracle.

Three states for any hash

StateServes
GeneratedNothing saved — a deterministic avatar seeded by the hash, generated on the fly, nothing stored.
DesignedA saved option config, pre-rendered to PNG variants in the object store at save time. Includes ✨ Ask AI output.
UploadedA normalized photo, pre-rendered to PNG variants at upload time.

Generated avatars use DiceBear personas by default (the read path's d= also accepts initials, identicon, bottts). Because the seed is the hash, the same person gets the same generated face in every app, forever, with zero storage. Designed avatars — including those seeded by ✨ Ask AI, which target the avataaars collection — may use any collection the editor supports.

✨ Ask AI, briefly

Ask AI turns a photo into a designed avatar: an on-infrastructure vision model reads coarse descriptors (skin tone, hair, glasses, facial hair, …), then Portrait's own deterministic map turns those into avataaars options that seed the Design editor. The user tweaks and saves through the normal designed path — it's assisted, not an exact likeness, and the photo is discarded, never stored. See POST /me/avatar/from-photo.

Accounts & claims

An account is not an email. A Portrait account anchors on its WorkOS user; emails are claims attached to it, each proven by a clicked magic link. One account may claim several addresses (work + personal), and every verified claim resolves to the same avatar — so one face follows a person across every address they use.

This dissolves "change my email" as a feature: you add a claim, verify it, set it primary, and drop the old one. There's always at least one primary — it can't be removed without promoting another first.

A verified claim is globally exclusive: one verified hash, one account, first claim wins. A second account attempting the same address is refused. Moving an address between accounts means releasing it from the first.

Verification and login are the same act

A clicked magic link proves control of an inbox. That single proof serves both purposes:

Magic link sent toResult
Unclaimed addressCreate account; that address becomes primary
Address verified on account ALog into A
Address claimed from a session on AVerify it onto A
Address verified on another account BRefuse — already connected

Because verification is login, a magic link to a claimed address always logs into its owner — it never forks a new account off a secondary address. (This is a known Gravatar wart that Portrait fixes; see How Portrait differs from Gravatar.)

The read path never touches the database

This is the central design property. Both saved states are rendered into the object store when the user saves, so a read is:

object-store lookup → hit means serve the stored PNG · miss means generate from the hash

No database on the hot path. The DB can be down and not a single avatar breaks anywhere in the estate.

  • Reads (media.portrait.intrasys.ai) are public and unauthenticated, like Gravatar.
  • Writes (api.portrait.intrasys.ai) require a Portrait WorkOS session and only ever act on hashes the session user has verified.

One face across many emails — write fanout, not lookup

"One avatar per account" means forcing identical bytes into each verified hash's independently-keyed slot. Portrait does this on write (a rare, human-speed save), never on read:

EventStorage action
Save avatar (design or upload)Render variants once → write under every verified hash
Claim a new email (verified)Server-side copy the existing variants to the new hash
Release a claimDelete that hash's objects → reverts to generated
Delete avatarDelete objects under every verified hash

An alias table (hash → account) would be tempting but puts a lookup in front of every read, including every miss — trading away the one property worth most here. Portrait rejects it by design.

How Portrait differs from Gravatar

Gravatar is the reference design, matched wherever matching is free — identical hash spec, aligned params, the same ~5-minute cache default, one-<img> integration. The deliberate differences:

GravatarPortrait
Sign in with secondary emailsteals the address into a new accountlogs into the owning account
Customise generated artpick from a galleryfull option editor + ✨ Ask AI from a photo
Avatar upload APInone documented — web UI onlyPOST /me/avatar
Different image per emailyesno — one avatar per account
Content rating (r=)user self-ratesnone — r= accepted, ignored
Profile data (bio, links)yes, expandingnon-goal — the image only
HostingAutomattic SaaSself-hosted Cloudflare Worker