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
| State | Serves |
|---|---|
| Generated | Nothing saved — a deterministic avatar seeded by the hash, generated on the fly, nothing stored. |
| Designed | A saved option config, pre-rendered to PNG variants in the object store at save time. Includes ✨ Ask AI output. |
| Uploaded | A 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 to | Result |
|---|---|
| Unclaimed address | Create account; that address becomes primary |
| Address verified on account A | Log into A |
| Address claimed from a session on A | Verify it onto A |
| Address verified on another account B | Refuse — 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:
| Event | Storage 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 claim | Delete that hash's objects → reverts to generated |
| Delete avatar | Delete 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:
| Gravatar | Portrait | |
|---|---|---|
| Sign in with secondary email | steals the address into a new account | logs into the owning account |
| Customise generated art | pick from a gallery | full option editor + ✨ Ask AI from a photo |
| Avatar upload API | none documented — web UI only | POST /me/avatar |
| Different image per email | yes | no — one avatar per account |
Content rating (r=) | user self-rates | none — r= accepted, ignored |
| Profile data (bio, links) | yes, expanding | non-goal — the image only |
| Hosting | Automattic SaaS | self-hosted Cloudflare Worker |