# auth.sv Public Avatars: SparkVault API Reference

> Show a SparkVault person's avatar with one img tag. The public avatar URL at my.auth.sv needs no SDK, no API key, no account, and no CORS - resolve it from an SVID or from the SHA-256 of an email or phone number.

Canonical: https://sparkvault.com/api/docs/auth-sv/ · OpenAPI: https://sparkvault.com/openapi.yaml

## 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
<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](/api/docs/products/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](/api/docs/sdk-js/), 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](#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

| 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](#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](#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
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 `404`s.

```javascript
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`;
}
```

```node.js
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`;
}
```

```python
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"
```

```ruby
require "digest"

def avatar_url(email)
  digest = Digest::SHA256.hexdigest(email.strip.downcase)
  "https://my.auth.sv/#{digest}/avatar"
end
```

```php
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";
}
```

```go
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"
}
```

```java
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";
}
```

```c#
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";
}
```

```shell
# 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`.

```html
<!-- 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">
```

```react
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' }}
    />
  );
}
```

```next.js
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' }}
    />
  );
}
```

```vue
<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
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](#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](/api/docs/products/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 |

> **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
// 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
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 |

> **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](#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 |

> **The hash lookup reveals one bit, on purpose**
>
> 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:

```bash
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](/api/docs/sdk-js/).

> **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.
