auth.sv - Public Avatars v1

Overview

auth.sv is where a person manages their own SparkVault identity: their verified emails and phones, passkeys, active sessions, the sites they have signed into, and the one photo they choose to publish. It belongs to the person, not to your account.

That published photo is the only part of auth.sv you integrate against, and it is a plain image URL on a second host, my.auth.sv. Point an <img> at it and you are done:

html The whole integration
<img src="https://my.auth.sv/ing_019e6f58f7d27c6ebe93cf08b1e6c19b/avatar"
     alt="" width="40" height="40" loading="lazy"
     referrerpolicy="no-referrer"
     style="border-radius:50%;object-fit:cover">

No SDK

A URL in an image tag

No Key

Unauthenticated, no account needed

No CORS

Embed it, never fetch it

Always 200

A valid URL always renders

The Hosts

HostWhat it isWho talks to it
my.auth.sv The public avatar edge. Static images, served from the edge. Your pages, via <img>
auth.sv The person's own portal: identifiers, passkeys, sessions, connected sites, and the avatar they publish. The person, in a browser
auth.sparkvault.com The Identity OIDC issuer and sign-in ceremony origin. Your app's auth flow
This page stands alone

Nothing here requires a SparkVault account, an API key, the JavaScript SDK, or any prior SparkVault integration. If all you want is avatars, this page is the whole manual. If you also sign people in with SparkVault, the picture claim hands you the same URL already resolved.

The Avatar URL

GET https://my.auth.sv/{selector}/avatar

Resolve a person's public avatar. A well-formed selector always answers 200 with an image: their photo when one is published and visible, otherwise a neutral placeholder.

Path Parameters

ParameterTypeRequiredDescription
selector string Required Either an SVIDing_ followed by exactly 32 lowercase hex characters — or an identifier hash: exactly 64 lowercase hex characters, the SHA-256 of a normalized email or phone number. Anything else returns 404

The path is case-sensitive and the hex must be lowercase. Query strings are ignored, not honored: there is no ?s= size parameter and no ?d= default-image parameter.

Which Selector Do You Have?

What you holdSelector to useHow to get it
An ID token from SparkVault sign-in Use the picture claim verbatim It is already this URL. See The picture Claim
An SVID you stored at sign-up {svid}/avatar The sub / svid claim from any SparkVault token
Only an email address or phone number {sha256hex}/avatar Hash it yourself. See Hashing an Email or Phone
Neither Do not embed There is no search, listing, or reverse lookup
A well-formed selector never 404s

Every reason a photo can be absent — the selector matches nobody, the person turned their avatar off, that identifier's lookup is switched off, they never uploaded one — answers identically: 200 with the same placeholder bytes, the same ETag, the same headers. A 404 means your selector is malformed (uppercase hex, wrong length, a stray prefix), never that the person is unknown.

Hashing an Email or Phone

The hash is a plain lowercase-hex SHA-256 of the normalized identifier. No salt, no prefix, no type tag. Normalize exactly as described — SparkVault applies the same two rules and nothing more:

  • Email — trim surrounding whitespace, then lowercase. "  Ada@Example.COM " becomes ada@example.com.
  • Phone — E.164 including the leading +, with whitespace removed. "+1 415 555 0123" becomes +14155550123.

Test Vectors

Check your implementation against these before you wire it to real data. If your digest does not match, your normalization is wrong — and a wrong hash fails silently, returning a perfectly valid placeholder rather than an error.

text Known-good pairs
ada@example.com
  b5fc85e55755f9e0d030a10ab4429b6b2944855f9a0d60077fe832becbc41d72

+14155550123
  36a2cef4ff9bf7a1abd2a93359136b870393be4106e6c5d19b72ff564f9deca4
Normalize exactly this much, and no more

Every extra canonicalization step produces a different hash and therefore a silent miss. Do not strip +tags, do not remove dots from Gmail addresses, do not apply Unicode folding or punycode conversion, and do not reformat the phone number beyond deleting whitespace — a number without its leading +, or with dashes or parentheses left in, hashes to something else.

Uppercase hex is the other silent killer. Convert.ToHexString in .NET and %X formatters emit uppercase; lowercase the digest before building the URL or the request 404s.

async function avatarUrl(email) {
  const normalized = email.trim().toLowerCase();
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalized));
  const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
  return `https://my.auth.sv/${hex}/avatar`;
}
import { createHash } from 'node:crypto';

export function avatarUrl(email) {
  const hex = createHash('sha256').update(email.trim().toLowerCase()).digest('hex');
  return `https://my.auth.sv/${hex}/avatar`;
}

// Phone: E.164 with whitespace removed, the leading + kept.
export function avatarUrlForPhone(phone) {
  const hex = createHash('sha256').update(phone.replace(/\s+/g, '')).digest('hex');
  return `https://my.auth.sv/${hex}/avatar`;
}
import hashlib


def avatar_url(email: str) -> str:
    normalized = email.strip().lower()
    digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
    return f"https://my.auth.sv/{digest}/avatar"
require "digest"

def avatar_url(email)
  digest = Digest::SHA256.hexdigest(email.strip.downcase)
  "https://my.auth.sv/#{digest}/avatar"
end
function avatar_url(string $email): string
{
    // mb_strtolower, not strtolower: strtolower is ASCII-only and leaves a
    // non-ASCII capital untouched, which hashes to a different digest.
    $digest = hash('sha256', mb_strtolower(trim($email), 'UTF-8'));
    return "https://my.auth.sv/{$digest}/avatar";
}
import (
	"crypto/sha256"
	"encoding/hex"
	"strings"
)

func AvatarURL(email string) string {
	sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email))))
	return "https://my.auth.sv/" + hex.EncodeToString(sum[:]) + "/avatar"
}
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Locale;

static String avatarUrl(String email) throws Exception {
    // Locale.ROOT matters: the Turkish locale lowercases "I" to a dotless i.
    String normalized = email.strip().toLowerCase(Locale.ROOT);
    byte[] digest = MessageDigest.getInstance("SHA-256")
        .digest(normalized.getBytes(StandardCharsets.UTF_8));
    return "https://my.auth.sv/" + HexFormat.of().formatHex(digest) + "/avatar";
}
using System.Security.Cryptography;
using System.Text;

static string AvatarUrl(string email)
{
    var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(email.Trim().ToLowerInvariant()));
    // Convert.ToHexString returns UPPERCASE; the URL requires lowercase.
    return $"https://my.auth.sv/{Convert.ToHexString(bytes).ToLowerInvariant()}/avatar";
}
# Normalize first (trim + lowercase), then hash. Use sha256sum on Linux.
hex=$(printf '%s' 'ada@example.com' | shasum -a 256 | cut -d' ' -f1)
echo "https://my.auth.sv/$hex/avatar"

Embedding

This is an image URL and nothing else. There is no redirect to follow, no API call to make, no token to attach, no CORS preflight to satisfy, and no onerror fallback to write — the URL always renders. Hotlinking is the intended usage: serve it straight from my.auth.sv.

<!-- Beside the person's name: the image is decorative, so alt is empty. -->
<span class="user">
  <img src="https://my.auth.sv/ing_019e6f58f7d27c6ebe93cf08b1e6c19b/avatar"
       alt="" width="40" height="40" loading="lazy"
       referrerpolicy="no-referrer"
       style="border-radius:50%;object-fit:cover">
  Ada Lovelace
</span>

<!-- Standing alone: name the person in alt. -->
<img src="https://my.auth.sv/b5fc85e55755f9e0d030a10ab4429b6b2944855f9a0d60077fe832becbc41d72/avatar"
     alt="Ada Lovelace" width="96" height="96" loading="lazy"
     referrerpolicy="no-referrer">
export function Avatar({ selector, name, size = 40 }) {
  return (
    <img
      src={`https://my.auth.sv/${selector}/avatar`}
      alt={name ? `${name}'s avatar` : ''}
      width={size}
      height={size}
      loading="lazy"
      decoding="async"
      referrerPolicy="no-referrer"
      style={{ borderRadius: '50%', objectFit: 'cover' }}
    />
  );
}
import Image from 'next/image';

// `unoptimized` is required: without it next/image proxies every avatar
// through your server, which breaks revocation and concentrates every
// fetch on one IP. See "Common Mistakes" below.
export function Avatar({ selector, name, size = 40 }) {
  return (
    <Image
      src={`https://my.auth.sv/${selector}/avatar`}
      alt={name ? `${name}'s avatar` : ''}
      width={size}
      height={size}
      unoptimized
      referrerPolicy="no-referrer"
      style={{ borderRadius: '50%', objectFit: 'cover' }}
    />
  );
}
<script setup>
const props = defineProps({ selector: String, name: String, size: { type: Number, default: 40 } });
</script>

<template>
  <img
    :src="`https://my.auth.sv/${props.selector}/avatar`"
    :alt="props.name ? `${props.name}'s avatar` : ''"
    :width="props.size"
    :height="props.size"
    loading="lazy"
    referrerpolicy="no-referrer"
    style="border-radius:50%;object-fit:cover"
  />
</template>

Sizing

There are no URL sizing parameters. Set width and height (which also reserves layout space and avoids a shift) and let CSS do the rest. Images are always square, so border-radius:50% gives you a circle with no cropping surprises.

Content Security Policy

If your pages set a CSP, allow the host in img-src. Nothing else is needed — no connect-src entry, because nothing is ever fetched:

http The only directive that matters
Content-Security-Policy: img-src 'self' data: https://my.auth.sv

Referrer

referrerpolicy="no-referrer" is recommended on every avatar embed. Without it the browser sends the URL of the page doing the embedding to my.auth.sv on each load. Nothing depends on that header, so suppressing it costs you nothing.

Common Mistakes

Each of these is code an integration writes by default, and each one breaks something:

Do notWhy it breaksInstead
fetch() the URL from a browser The response carries no Access-Control-Allow-Origin, so the browser blocks it and the promise rejects. A plain GET never preflights, so there is no OPTIONS to fix; anything that does preflight is answered 405 Embed it in an <img>
Add crossorigin to the tag Opts the image into a CORS check that has no headers to satisfy it, so the load fails Omit the attribute
Proxy it through an image optimizer next/image, Nuxt Image, Gatsby, Cloudinary and imgix fetch URLs all re-host the bytes: revocation stops working and every fetch collapses onto one server IP Render the URL directly (unoptimized in Next.js)
Copy the bytes to your own CDN or database You will keep publishing a photo the person has already withdrawn Hotlink it; that is what it is for
Write an onerror fallback for a missing photo The URL always renders, so the handler never fires and your fallback is dead code Branch on the picture claim before you embed
Uppercase the hex, or store the SVID case-folded The path is case-sensitive; B5FC… is a 404 Lowercase the digest at the point you build the URL
Fetch many avatars from one server Server-side bulk fetching trips the edge rate limit on this host Let each visitor's browser load them
Branch application logic on X-SV-Avatar It is a debugging aid, not a contract, and its values are not stable Use the picture claim as the presence signal

The picture Claim

If you sign people in with SparkVault Identity, you never need to build an avatar URL: the token already carries one. The picture claim is the standard OIDC name (OIDC Core 1.0 §5.1), so libraries that map picture to a profile image work with no custom wiring.

PropertyValue
WhereThe OIDC id_token and the SDK / simple-mode token. picture is not a UserInfo claim, so read it from the token rather than from /userinfo
Valuehttps://my.auth.sv/{svid}/avatar — the same URL you would build from the sub claim
Present whenThe person has published a photo and their avatar is switched on. Otherwise the key is absent from the payload, never null
FreshnessResolved once per sign-in. A long-lived session keeps the value it was issued with; it refreshes at the person's next sign-in
Its presence is the only supported way to know there is a real photo

The URL itself cannot tell you: it renders the placeholder just as happily as a photo, deliberately and indistinguishably. So to show your own fallback — initials, a brand glyph, a team default — branch on the claim being absent and skip the embed entirely. Waiting on an image load that never fails will not work.

There is no equivalent signal for hash lookups, by design. With only an email address you either embed and accept the silhouette, or you ask the person.

javascript Sign-in to rendered pixels
// Verify the signature before trusting a claim: an unverified decode reads the
// payload as given, so `picture` would be whatever the token's holder wrote.
const claims = await verifyIdToken(idToken);

if (claims.picture) {
  // Set as a property, never interpolated into markup.
  const img = document.createElement('img');
  img.src = claims.picture;
  img.alt = '';
  img.width = img.height = 40;
  img.loading = 'lazy';
  img.referrerPolicy = 'no-referrer';
  img.style.borderRadius = '50%';
  slot.replaceChildren(img);
} else {
  slot.replaceChildren(renderInitials(claims.identity));  // your own placeholder, as a node
}
javascript A contact list keyed by email
import { createHash } from 'node:crypto';

const avatarUrl = (email) =>
  `https://my.auth.sv/${createHash('sha256').update(email.trim().toLowerCase()).digest('hex')}/avatar`;

const rows = contacts.map((c) => `
  <li>
    <img src="${avatarUrl(c.email)}" alt="" width="32" height="32"
         loading="lazy" referrerpolicy="no-referrer" style="border-radius:50%">
    ${escapeHtml(c.name)}
  </li>
`).join('');

Response Contract

Status Codes

RequestResponse
Well-formed selector, photo published and visible200 with the photo, X-SV-Avatar: stored
Well-formed selector, no visible photo for any reason200 with the placeholder, X-SV-Avatar: default
Well-formed selector, storage read failing200 with the placeholder, X-SV-Avatar: unavailable, Cache-Control: no-store
Malformed selector or unknown path404, empty body, no X-SV-Avatar
If-None-Match carrying the current ETag304, no body, every header the 200 carries except Content-Length
If-None-Match: *304 only when a photo is stored. The placeholder answers 200, so a revalidating cache is never told a withdrawn photo is still current
Any method other than GET or HEAD405 with Allow: GET, HEAD

What You Get Back

PropertyValue
FormatA photo is image/png or image/jpeg; the placeholder is always image/png. Read Content-Type, do not assume
ShapeAlways square. A photo is 512×512 when uploaded through the portal, and is never smaller than 64 or larger than 1024. The placeholder is 512×512
SizeA photo is 600 KB at most, usually far less. The placeholder is about 5 KB
CachingCache-Control: public, max-age=300 for a photo, public, max-age=60 for the placeholder. Both carry an ETag and answer If-None-Match with a 304
MethodsGET and HEAD. A HEAD reports the Content-Length its GET would send
DiagnosticsX-SV-Avatar: stored, default, or unavailable
HardeningX-Content-Type-Options: nosniff and X-Robots-Tag: noindex on every response. Every upload is structurally rebuilt server-side, dropping EXIF, color profiles, text chunks and any non-image payload
The placeholder

When there is no visible photo the URL serves a neutral grey head-and-shoulders silhouette — achromatic and deliberately unbranded, so it does not plant SparkVault's identity on your page. Its bytes are a constant: identical for every selector, never derived from it. There is no parameter that turns it off.

X-SV-Avatar is diagnostics, not a contract

It exists so you can tell a broken integration apart from a person who simply has no visible photo. Do not branch application logic on it and do not treat its values as stable. The picture claim is the supported presence signal.

Caching and Revocation

Please honor the cache headers. Revocation works by the object ceasing to exist: a withdrawn photo stops being served at the bucket immediately and clears everywhere, edge caches included, within the stored copy's 300-second TTL, after which the URL serves the placeholder. There is no purge path. If you mirror the bytes onto your own storage, or cache them indefinitely, you will keep publishing a photo the person has already withdrawn.

Publication runs the same clock in reverse. The placeholder is cacheable for 60 seconds at the URL a photo will occupy, so a newly published avatar can take up to a minute to appear at a URL that was requested moments earlier. Neither the placeholder nor a 404 is ever stored in the edge cache, which is what keeps that window short.

Embedded images are fetched by your visitors' browsers, so ordinary usage never approaches the edge rate limit on this host. Bulk-fetching many avatars from a single server address will trip it.

Verify Your Integration

One request tells you which of the three responses you are getting, without opening a browser:

bash Check any selector
curl -sI https://my.auth.sv/ing_019e6f58f7d27c6ebe93cf08b1e6c19b/avatar \
  | grep -iE '^HTTP/|^content-type:|^cache-control:|^x-sv-avatar:'

# HTTP/2 200
# content-type: image/jpeg
# cache-control: public, max-age=300
# x-sv-avatar: stored
SymptomLikely cause
Broken image on the page A 404: uppercase hex, wrong digest length, a mistyped SVID prefix. Or a CSP blocking my.auth.sv in img-src, or a stray crossorigin attribute
x-sv-avatar: default for someone you know has a photo Your hash is wrong (over-normalized input, or the phone lost its leading +), or they switched that identifier's lookup off, or their master switch is off
A CORS error in the console You are fetching the URL instead of embedding it
The old photo is still showing Something on your side cached it past 300 seconds: an image proxy, a CDN, or a copy in your own storage
A new photo has not appeared yet Publication latency. Wait 60 seconds
Requests suddenly blocked Server-side bulk fetching tripped the edge rate limit. Move the loads into visitors' browsers

The auth.sv Portal

auth.sv is the person's own account page, not yours. Anyone who has verified with SparkVault Identity can sign in there and manage:

  • Verified emails and phone numbers, and the per-identifier avatar lookup toggles
  • The public avatar itself: upload, replace, remove, and the master visibility switch
  • Passkeys, two-factor authentication, and backup identifiers
  • Active sessions, with the ability to end them individually or everywhere
  • Connected sites: which applications they have signed into, and revoking that access

You do not proxy or wrap the portal. To send someone to their photo, link https://auth.sv/public directly. The SDK's portalUrl() builds deep links to the other screens — home, identity, security, sessions, and sites — and has no public section, so asking it for one throws. See JavaScript SDK.

Avatars are included

The public avatar surface carries no charge and no quota. It is part of every SparkVault identity, and embedding it costs the embedding site nothing.