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:
<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
| Host | What it is | Who 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 |
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
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
| Parameter | Type | Required | Description |
|---|---|---|---|
selector |
string | Required | Either an SVID — ing_ 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 hold | Selector to use | How 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 |
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 "becomesada@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.
ada@example.com
b5fc85e55755f9e0d030a10ab4429b6b2944855f9a0d60077fe832becbc41d72
+14155550123
36a2cef4ff9bf7a1abd2a93359136b870393be4106e6c5d19b72ff564f9deca4
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:
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 not | Why it breaks | Instead |
|---|---|---|
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.
| Property | Value |
|---|---|
| Where | The 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 |
| Value | https://my.auth.sv/{svid}/avatar — the same URL you would build from the sub claim |
| Present when | The person has published a photo and their avatar is switched on. Otherwise the key is absent from the payload, never null |
| Freshness | Resolved once per sign-in. A long-lived session keeps the value it was issued with; it refreshes at the person's next sign-in |
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.
// 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
}
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
| Request | Response |
|---|---|
| Well-formed selector, photo published and visible | 200 with the photo, X-SV-Avatar: stored |
| Well-formed selector, no visible photo for any reason | 200 with the placeholder, X-SV-Avatar: default |
| Well-formed selector, storage read failing | 200 with the placeholder, X-SV-Avatar: unavailable, Cache-Control: no-store |
| Malformed selector or unknown path | 404, empty body, no X-SV-Avatar |
If-None-Match carrying the current ETag | 304, 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 HEAD | 405 with Allow: GET, HEAD |
What You Get Back
| Property | Value |
|---|---|
| Format | A photo is image/png or image/jpeg; the placeholder is always image/png. Read Content-Type, do not assume |
| Shape | Always 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 |
| Size | A photo is 600 KB at most, usually far less. The placeholder is about 5 KB |
| Caching | Cache-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 |
| Methods | GET and HEAD. A HEAD reports the Content-Length its GET would send |
| Diagnostics | X-SV-Avatar: stored, default, or unavailable |
| Hardening | X-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 |
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.
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.
Privacy and Consent
The photo belongs to the person, and they hold two switches in their auth.sv portal. Both are on by default, and either one can go off at any moment with no notice to you:
| Control | Where | Effect |
|---|---|---|
| Public avatar | auth.sv/public |
The master switch. Off means every avatar URL for that person — SVID and hashes alike — serves the placeholder, and the picture claim stops being issued |
| Per-identifier lookup | auth.sv/identity |
One toggle beside each verified email and phone. Off means that identifier's hash URL serves the placeholder. The SVID URL and the picture claim are unaffected |
Answering 200 everywhere buys no privacy: the placeholder is a fixed length with a fixed
ETag, so a single request still answers "does this address have a public SparkVault
avatar?". That disclosure is the feature — it is what makes lookup by email work at all —
and the person can switch it off per identifier, or turn the whole surface off, whenever they like.
Resolving avatars for addresses already in your own contact list is the intended use. Sweeping the host with addresses you do not hold, to discover who has a SparkVault account, is not.
Treat the URL as a live pointer, never as a fact you have learned. Do not persist the image, and do not persist a derived "this person has an avatar" flag — both go stale the moment the person changes their mind, and the URL is stable enough that you never need to.
Verify Your Integration
One request tells you which of the three responses you are getting, without opening a browser:
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
| Symptom | Likely 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.
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.