Identity v1

Overview

SparkVault Identity provides passwordless authentication for your applications. It acts as an OpenID Connect (OIDC) Identity Provider, allowing you to integrate secure authentication without managing passwords, passkeys, or verification infrastructure yourself.

OIDC 1.0

Standards Compliant

Ed25519

EdDSA Default, RS256 Opt-In

PKCE

S256 Required

Multi-Tenant

Per-Account Keys

Authentication Methods

  • SparkLink: Magic links sent via email, one-click verification
  • OTP: One-time codes sent via email, SMS, or voice
  • Passkeys: WebAuthn/FIDO2 with biometric authentication
  • Social Login: Google, GitHub, Apple, Microsoft, LinkedIn, Facebook
Managed Identity

SparkVault Identity maintains a durable SparkVault ID (SVID) and managed sessions for each verified person. Your application still owns its product profile and authorization model; Identity provides authentication, session lifecycle, passkeys, and revocation signals.

Quick Start

Get authentication working in under 5 minutes using the JavaScript SDK. The browser handles the branded verification UI; your backend verifies the signed JWT and maps the person by SVID.

1. Add the SDK

html
<script src="https://cdn.sparkvault.com/sdk/v1/sparkvault.js"></script>

2. Add a Login Button

javascript
const sv = SparkVault.init({ accountId: 'acc_your_account_id' });

document.querySelector('#login-btn').onclick = async () => {
  const { identity, token } = await sv.products.identity.signIn();
  console.log(identity, token);  // "user@example.com", "eyJhbG..."
};

3. Verify Token (Backend)

// npm install jose
import * as jose from 'jose';

const ISSUER = 'https://auth.sparkvault.com/acc_xxx';

// Caches the JWKS and picks the key by the token's `kid`.
const JWKS = jose.createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));

const { payload } = await jose.jwtVerify(token, JWKS, {
  issuer: ISSUER,
  audience: 'acc_xxx',
  algorithms: ['EdDSA']   // signIn() tokens are always EdDSA
});
const svid = payload.svid || payload.sub;
# pip install "pyjwt[crypto]"   — EdDSA needs the `cryptography` extra
import jwt
from jwt import PyJWKClient

ISSUER = "https://auth.sparkvault.com/acc_xxx"

# Caches the JWKS and picks the key by the token's `kid`.
jwks_client = PyJWKClient(f"{ISSUER}/.well-known/jwks.json")
signing_key = jwks_client.get_signing_key_from_jwt(token)

decoded = jwt.decode(
    token,
    signing_key.key,
    algorithms=["EdDSA"],   # signIn() tokens are always EdDSA
    issuer=ISSUER,
    audience="acc_xxx"
)
svid = decoded.get("svid") or decoded["sub"]
// go get github.com/lestrrat-go/jwx/v2
import (
    "context"

    "github.com/lestrrat-go/jwx/v2/jwk"
    "github.com/lestrrat-go/jwx/v2/jwt"
)

const issuer = "https://auth.sparkvault.com/acc_xxx"
const jwksURL = issuer + "/.well-known/jwks.json"

// Caches the JWKS; jwx picks the key by `kid` and honours the JWK's `alg`,
// so the same code verifies an EdDSA or an RS256 token.
cache := jwk.NewCache(context.Background())
cache.Register(jwksURL)

tok, err := jwt.ParseString(tokenString,
    jwt.WithKeySet(jwk.NewCachedSet(cache, jwksURL)),
    jwt.WithIssuer(issuer),
    jwt.WithAudience("acc_xxx"),
)
if err != nil {
    // Reject the request: signature, issuer, audience, or expiry failed.
}

svid := tok.Subject()
if v, ok := tok.Get("svid"); ok {
    if s, isString := v.(string); isString && s != "" {
        svid = s
    }
}
# Gemfile: gem 'jwt'  +  gem 'jwt-eddsa'
#
# ruby-jwt 3.x ships EdDSA and its OKP JWK class in the separate jwt-eddsa gem.
# Without it, JWT::JWK::Set silently drops the Ed25519 key and decode fails with
# "Could not find public key for kid" — which reads like a server-side bug.
require 'jwt'
require 'jwt/eddsa'
require 'json'
require 'net/http'

ISSUER = 'https://auth.sparkvault.com/acc_xxx'

# JWT::JWK::Set picks the key by the token's `kid`.
jwks = JWT::JWK::Set.new(
  JSON.parse(Net::HTTP.get(URI("#{ISSUER}/.well-known/jwks.json")))
)

decoded, = JWT.decode(token, nil, true, {
  algorithms: ['EdDSA'],   # signIn() tokens are always EdDSA
  jwks: jwks,
  iss: ISSUER,
  aud: 'acc_xxx',
  verify_iss: true,
  verify_aud: true
})
svid = decoded['svid'] || decoded['sub']
// composer require firebase/php-jwt
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;

$issuer = 'https://auth.sparkvault.com/acc_xxx';

// parseKeySet returns a kid => Key map and carries each JWK's `alg`,
// so decode() selects the right key and algorithm on its own.
$jwks = json_decode(file_get_contents("{$issuer}/.well-known/jwks.json"), true);
$keys = JWK::parseKeySet($jwks);

$decoded = JWT::decode($token, $keys);

// php-jwt checks the signature and expiry only — check iss/aud yourself.
if ($decoded->iss !== $issuer) { throw new Exception('Invalid issuer'); }
if ($decoded->aud !== 'acc_xxx') { throw new Exception('Invalid audience'); }

$svid = $decoded->svid ?? $decoded->sub;
// The token signIn() returns is Ed25519 (EdDSA). Microsoft.IdentityModel does
// not implement OKP/Ed25519 keys, so it cannot verify this token on any
// algorithm setting — JsonWebKeySet skips the OKP entry and no signing key
// resolves. Two ways forward on .NET:
//
//   1. Verify EdDSA with a library that supports it (BouncyCastle exposes
//      Ed25519Signer, over the `x` member of the OKP JWK).
//   2. Use the OIDC authorization-code flow instead of the widget, and register
//      that client with id_token_signed_response_alg: "RS256". Its id_token is
//      then RSA-signed and Microsoft.IdentityModel verifies it natively.
//      See "Quick Start (OIDC)" below.
//
// Option 2, for reference. NOTE the audience: an OIDC id_token is issued TO A
// CLIENT, so `aud` is the client_id — not the account id the widget token uses.
using System.IdentityModel.Tokens.Jwt;
using System.Threading;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;

const string issuer = "https://auth.sparkvault.com/acc_xxx";
const string clientId = "your_registered_client_id";

// Keep `sub` as `sub` instead of remapping it to ClaimTypes.NameIdentifier.
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;

// Caches the discovery document and the JWKS, refreshing on an unknown kid.
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
    $"{issuer}/.well-known/openid-configuration",
    new OpenIdConnectConfigurationRetriever());
var oidcConfig = await configManager.GetConfigurationAsync(CancellationToken.None);

var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(idToken, new TokenValidationParameters {
    IssuerSigningKeys = oidcConfig.SigningKeys,
    ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
    ValidateIssuer = true,
    ValidIssuer = issuer,
    ValidateAudience = true,
    ValidAudience = clientId
}, out _);
var svid = principal.FindFirst("svid")?.Value ?? principal.FindFirst("sub")?.Value;
Signing algorithm

Tokens are EdDSA (Ed25519) signed by default. A registered OIDC client that sets id_token_signed_response_alg: "RS256" at creation gets both its id_token and its access token signed RS256 instead — the escape hatch for stacks whose JWT library has no EdDSA support (.NET being the common one). The opt-in is scoped to that client's OIDC tokens: SDK/simple-mode tokens and simple-redirect callback signatures are always EdDSA. The JWKS publishes both keys, so verifiers select by kid either way.

A client that registers no algorithm gets EdDSA, not RS256. That is a deliberate deviation from OIDC Dynamic Registration §2, which names RS256 as the default for an omitted id_token_signed_response_alg: every SparkVault integration verifies EdDSA, and applying the spec default would re-sign their tokens with a key their verifiers have never fetched. Set the field explicitly to opt in.

That's It!

The SDK handles the entire authentication UI, passkey enrollment, email/SMS verification, social login flows, and error handling. You just call signIn() and get back a verified identity.

Integration Options

Programmatic

sv.products.identity.signIn(): Opens the first-party sign-in popup. Best for most web apps. Also call sv.products.identity.handleRedirectResult() on page load. It delivers the result and silently self-heals a sign-in whose popup result was lost (e.g. a mobile tab discarded mid-login).

Attach to a Button

sv.products.identity.attach('.login-btn'): Binds the popup to clicks on matching elements.

Hosted Page

Server mints a verify-session, redirects the user to /verify?session=.... No JavaScript required.

Session Management

Every successful SparkVault Identity verification resolves the person to a canonical SVID and opens a managed Identity session. SDK/simple integrations receive a signed verification JWT that includes the SVID and session ID. Full OIDC integrations also receive short-lived access tokens plus rotating refresh tokens.

How It Works
  1. User authenticates via SparkVault Identity (passkey, OTP, social login, etc.)
  2. SparkVault resolves or creates the person's SVID and records the connected site session
  3. SDK/simple mode returns a signed JWT; OIDC returns id_token, access_token, and refresh_token
  4. Your backend verifies the token (signature, issuer, audience, expiration)
  5. Your application maps product data by SVID; use OIDC/BFF when you want SparkVault to own refresh, revocation, and session lifecycle

Refresh and Introspection

OIDC clients refresh through /token with grant_type=refresh_token. Refresh rotates the session token and mints a new short-lived access token. Logout calls /revoke, which deletes the managed Identity session. Servers can call /introspect to verify that an access token is still active, was issued after the session's current cutoff, and is not blocked by the user's connected-site policy. /introspect is confidential-clients-only: it authenticates with client_secret_basic or client_secret_post, so a public (PKCE, no-secret) client cannot use it.

SDK/simple mode keeps the integration lightweight:

  • You verify the signed JWT using the product JWKS endpoint.
  • You map users by SVID from sub or svid, not by email hash.
  • You own only your app-specific state; use OIDC/BFF when you want SparkVault-owned refresh-token lifecycle, revocation, and introspection.
Subject stability

A SVID is minted once and never rotates — no re-verification, recovery, or credential change ever alters it. Two user-initiated lifecycle events can end a SVID: (1) identity merge — when a person proves control of an identifier already linked to a second identity (step-up-verified in the auth.sv portal), the two records become one; the surviving SVID is kept and the other is permanently deleted, so the person's future logins present the surviving SVID as sub. (2) identity deletion — permanently ends a SVID; a later re-signup is a new identity with a new SVID. If a login presents a verified identifier you already have bound to a different SVID, treat it as a possible post-merge return of the same person and re-bind per your own policy.

BFF Pattern

For browser and server-rendered apps, exchange OIDC codes server-side and store refresh/session material only in encrypted or signed HttpOnly, Secure, SameSite=Lax cookies. Keep refresh tokens out of browser JavaScript. Validate access JWTs locally with cached JWKS for normal API requests, and reserve /introspect for sensitive mutations, account settings, admin/moderation, suspicious sessions, or short cached hard checks.

Lockdown and Site Blocks

Lockdown revokes live sessions and forces old access tokens inactive through min_iat. Connected-site blocks prevent new logins, refresh-token redemption, and active introspection for that site.

End-User Portal (auth.sv)

People manage their own SparkVault identity in the auth.sv portal: active sessions, connected sites, passkeys, backup email/phone identifiers, two-factor authentication, and lockdown. The JavaScript SDK's portalUrl() helper builds deep links into the portal (home, identity, security, sessions, or sites screens) so your app can hand people off to self-service session and passkey management instead of rebuilding those surfaces.

Recommended Pattern

javascript
// Frontend: Trigger verification
async function login() {
  const result = await sparkvault.products.identity.signIn();

  // Send token to YOUR backend for verification
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token: result.token })
  });

  // Backend verifies the signed proof and maps the person by SVID.
  // For production session replacement, use the OIDC/BFF flow below.
}

// Backend: Verify token and map your app data by SVID
app.post('/api/auth/login', async (req, res) => {
  const { token } = req.body;

  // Verify SparkVault token (see JWKS verification examples below)
  const claims = await verifySparkVaultToken(token);
  const svid = claims.svid || claims.sub;

  // Use SVID as the stable foreign key. Keep profile, roles, uploads,
  // preferences, and moderation state in your own database.
  const user = await findOrCreateUserBySvid(svid, {
    identity: claims.identity,
    identityType: claims.identity_type
  });

  res.cookie('app_session', createAppSessionToken(user.id), {
    httpOnly: true,
    secure: true
  });

  res.json({ success: true, user });
});
SAML metadata URL requirements

A configured metadata_url must be a direct public https:// endpoint. URLs with embedded credentials and all redirects are rejected. SparkVault validates public DNS answers during the HTTPS connection, pins the validated destination, applies a 10-second total timeout, and accepts at most 1 MiB of metadata.

Cross-Domain SSO

One SparkVault Identity signs a person into every site your account runs. Each successful sign-in through the first-party popup (or hosted page) also establishes a 30-day SSO session on the Identity origin — a first-party HttpOnly cookie set in a top-level window, never a third-party context, so it is unaffected by Safari ITP or Chrome's third-party-cookie phase-out.

What the person sees

A person who signed in on one of your domains and later visits another clicks your sign-in button, and the popup offers a one-click “Continue as …” — no OTP, passkey ceremony, or typing. With several SparkVault accounts signed in on the browser (up to five), the popup instead lists them in an account chooser, most recent first: picking a row continues as that account, each row can be removed (which ends that account's SSO session in that browser), and “Use another account” starts a fresh interactive sign-in whose account joins the list. The result your page receives is the same shape as any other sign-in (the token's method/amr value is sso): verify it server-side and mint your own session exactly as usual. Pass signIn({ prompt: 'select_account' }) to always show the chooser — the affordance for a “switch account” button — even when only one account is signed in.

Sign-in is always an explicit gesture. The SDK never signs a visitor in silently on page load, and landing on a sibling domain does not by itself create a session — the silent re-mint inside handleRedirectResult() only completes a sign-in the person already started. For a browser-native one-click without the popup, add FedCM (signInWithFedcm() — see the JavaScript SDK); FedCM is also the one surface that can sign a person into a brand-new site in a single gesture, since the browser's account chooser carries the consent. Feature-detect it and fall back to signIn().

Requirements

  • One account, every domain verified. Cross-domain SSO connects the verified domains of a single account: fetch each apex's TXT record via POST /v1/domains/challenge, publish it, then claim the domain with POST /v1/domains — the claim verifies DNS and stores the domain only on success (an apex covers its subdomains, and a domain can be verified by only one account). Sites on separate SparkVault accounts do not share SSO.
  • A prior sign-in to your account. The one-click and silent paths only ever resume an existing relationship — the person's first sign-in on any of your domains establishes it. A silent path never creates a new relationship.
  • Account posture. The sso.enabled setting (on by default) controls participation; opting out makes every sign-in fully interactive even with a live SSO session.
Requiring a fresh authentication

A silent or one-click continuation reuses an authentication that may be up to 30 days old. When an entry point needs a recent one, send max_age (seconds) on the OIDC authorize request: a candidate authenticated longer ago than that is never minted silently, and max_age=0 forces an active re-authentication. The ID token's auth_time reports the real authentication time — the SSO session's creation on a silent mint — so it is a claim you can hold the flow to.

Sign-out expectations

Ending your own application session does not end the person's SparkVault SSO session — like any federated identity provider, the next sign-in click is a one-click “Continue as …” rather than a full re-verification. The person can end an SSO session themselves — by removing that account from the sign-in chooser, or from their auth.sv portal (deep-link there with portalUrl()), which also manages and revokes per-site access. If your application needs SparkVault-owned revocation — where ending the SparkVault session invalidates your app's sessions too — use the OIDC/BFF integration with /introspect and /revoke.

One brand per account

The sign-in popup, hosted page, and portal show your account's organization branding. All domains of an account share that one brand surface — weigh this when deciding whether differently-branded properties should share an account for SSO.

Integration Options

Choose the integration method that best fits your application architecture:

JavaScript SDK (Recommended)

Drop-in SDK with popup-based sign-in. One line of code to verify identity. Best for SPAs and modern web apps.

  • Easiest to implement
  • First-party popup flow
  • Works with any framework

View SDK Guide →

Full OIDC Flow

Standard OpenID Connect authorization code flow with PKCE. Ideal for server-rendered apps and SSO integrations.

  • Industry standard
  • Signed ID tokens (JWT)
  • Full security model

Simple Redirect

Simpler flow for basic integrations. Redirect to verify, receive signed callback. No token exchange required.

  • Simple implementation
  • No client secret
  • Ed25519 signed callbacks

Quick Start (OIDC)

Implement authentication in 4 steps:

1. Generate PKCE Challenge

javascript
// Generate cryptographically secure PKCE parameters
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64urlEncode(array);
}

async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64urlEncode(new Uint8Array(hash));
}

function base64urlEncode(buffer) {
  return btoa(String.fromCharCode(...buffer))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

// Generate and store for later verification
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = crypto.randomUUID();
const nonce = crypto.randomUUID();

sessionStorage.setItem('pkce_verifier', codeVerifier);
sessionStorage.setItem('auth_state', state);
sessionStorage.setItem('auth_nonce', nonce);

2. Redirect to Authorization

javascript
const authUrl = new URL('https://auth.sparkvault.com/{account_id}/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'your-client-id');
authUrl.searchParams.set('redirect_uri', 'https://your-app.com/auth/callback');
authUrl.searchParams.set('scope', 'openid email');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('nonce', nonce);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');

// Redirect user to Identity hosted login
window.location.href = authUrl.toString();

3. Handle Callback & Exchange Code

javascript
// In your /auth/callback route handler
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const returnedState = params.get('state');

// Validate state to prevent CSRF
if (returnedState !== sessionStorage.getItem('auth_state')) {
  throw new Error('Invalid state parameter');
}

// Exchange code for tokens (do this server-side!)
const response = await fetch('https://auth.sparkvault.com/{account_id}/token', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Basic ' + btoa('client_id:client_secret')
  },
  body: JSON.stringify({
    grant_type: 'authorization_code',
    code: code,
    redirect_uri: 'https://your-app.com/auth/callback',
    client_id: 'your-client-id',
    code_verifier: sessionStorage.getItem('pkce_verifier')
  })
});

const { id_token } = await response.json();

// Clean up
sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('auth_state');
sessionStorage.removeItem('auth_nonce');

4. Verify ID Token

javascript
import * as jose from 'jose';

// Module scope: createRemoteJWKSet caches the key set and re-fetches on an
// unknown kid. The JWKS carries an Ed25519 and an RSA key; jose selects by kid.
const jwksCache = new Map();
const jwksFor = (accountId) => {
  if (!jwksCache.has(accountId)) {
    jwksCache.set(accountId, jose.createRemoteJWKSet(
      new URL(`https://auth.sparkvault.com/${accountId}/.well-known/jwks.json`)
    ));
  }
  return jwksCache.get(accountId);
};

async function verifyIdToken(idToken, accountId, clientId, nonce) {
  const { payload } = await jose.jwtVerify(idToken, jwksFor(accountId), {
    issuer: `https://auth.sparkvault.com/${accountId}`,
    audience: clientId,
    algorithms: ['EdDSA']   // ['RS256'] when the client registers id_token_signed_response_alg
  });

  // jose checks signature, issuer, audience, and expiry. The nonce is yours.
  if (payload.nonce !== nonce) {
    throw new Error('Invalid nonce');
  }

  return payload; // { sub: svid, identity, identity_type, email?, phone_number?, amr, ... }
}
Token Exchange Server-Side

Always perform the token exchange (step 3) on your backend server. Never expose your client_secret in frontend code. The PKCE flow provides additional security even if the code is intercepted.

OIDC Endpoints

Base URL: https://auth.sparkvault.com/{account_id}

Discovery Document

GET /{account_id}/.well-known/openid-configuration

Returns the OIDC discovery document with all endpoint URLs and supported features.

json Response
{
  "issuer": "https://auth.sparkvault.com/acc_example",
  "authorization_endpoint": "https://auth.sparkvault.com/acc_example/authorize",
  "token_endpoint": "https://auth.sparkvault.com/acc_example/token",
  "introspection_endpoint": "https://auth.sparkvault.com/acc_example/introspect",
  "revocation_endpoint": "https://auth.sparkvault.com/acc_example/revoke",
  "end_session_endpoint": "https://auth.sparkvault.com/acc_example/end_session",
  "userinfo_endpoint": "https://auth.sparkvault.com/acc_example/userinfo",
  "jwks_uri": "https://auth.sparkvault.com/acc_example/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "prompt_values_supported": ["none", "login", "consent", "select_account"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["EdDSA", "RS256"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"],
  "revocation_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"],
  "introspection_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
  "claims_supported": ["iss", "sub", "aud", "exp", "iat", "auth_time", "nonce",
                       "email", "email_verified", "phone_number", "phone_number_verified",
                       "identity", "identity_type", "svid", "session_id", "amr", "acr",
                       "picture", "name", "given_name", "middle_name", "family_name"],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": ["openid", "email", "phone", "profile"]
}

JWKS (Public Keys)

GET /{account_id}/.well-known/jwks.json

Returns the JSON Web Key Set containing the tenant's Ed25519 and RSA-2048 public keys for verifying token signatures.

json Response
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "use": "sig",
      "alg": "EdDSA",
      "kid": "ed25519-key-id",
      "x": "base64url-encoded-public-key"
    },
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "rsa-key-id",
      "n": "base64url-encoded-modulus",
      "e": "AQAB"
    }
  ]
}
Two keys, select by kid

Every tenant publishes both keys: the Ed25519 key that signs by default, and the RSA-2048 key that signs for clients registered with id_token_signed_response_alg: "RS256". Each JWK carries its own alg, so select the key by the kid in the JWT header — never by array position. Cache the response for 5-10 minutes and re-fetch on an unknown kid. Standard JWKS clients (jose, PyJWT's PyJWKClient, php-jwt's parseKeySet, jwx's key set) do this for you. Two need a caveat: Ruby's JWT::JWK::Set resolves the Ed25519 key only with the jwt-eddsa gem loaded, and Microsoft.IdentityModel resolves the RSA key only — it has no EdDSA support at all, which is exactly what id_token_signed_response_alg: "RS256" exists for.

Authorization Endpoint

GET /{account_id}/authorize

Initiates the authorization flow. Validates parameters and redirects to the hosted login page.

Query Parameters (Required)

ParameterTypeRequiredDescription
response_type string Required Must be code
client_id string Required Your registered client ID
redirect_uri string Required Must match a pre-registered URI
scope string Required Space-separated scopes. Must include openid
state string Required Opaque value for CSRF protection. Returned unchanged.
code_challenge string Required Base64url-encoded SHA256 hash of code_verifier
code_challenge_method string Required Must be S256

Query Parameters (Optional)

ParameterTypeRequiredDescription
nonce string Optional Binds ID token to request. Returned in token claims.
prompt string Optional Values per prompt_values_supported: none, login, consent, select_account. none never shows UI — silent success, or login_required / account_selection_required on your redirect_uri; it cannot be combined with another value (invalid_request). select_account always shows the account chooser when at least one account is signed in.
max_age integer Optional Maximum acceptable age, in seconds, of the person's authentication. Must be a non-negative integer; any other value expresses no enforceable constraint and is read as absent rather than rejected, so a malformed max_age silently applies no freshness gate. max_age=0 forces an active re-authentication on every request. The returned ID token's auth_time is the value it is judged against.
max_age and freshness

A candidate whose authentication is older than max_age — or whose authentication time cannot be established at all — is refused at the mint. It is dropped before selection at /authorize, and refused again when the person picks an account, so it can never produce a credential; a stale chip clicked in the account chooser falls through to interactive login rather than returning one. Combined with prompt=none, a too-old session returns login_required rather than a stale credential.

Token Endpoint

POST /{account_id}/token

Exchanges an authorization code for tokens or rotates a refresh token. Requires client authentication.

Authorization Code Request Body

ParameterTypeRequiredDescription
grant_type string Required Must be authorization_code
code string Required The authorization code from callback
redirect_uri string Required Must match the authorize request
client_id string Required Your registered client ID
code_verifier string Required Original PKCE code verifier

Refresh Token Request Body

ParameterTypeRequiredDescription
grant_type string Required Must be refresh_token
refresh_token string Required Refresh token from the previous token response
client_id string Required Your registered client ID
Client Authentication Required

Include your client_secret via Basic auth header (Authorization: Basic base64(client_id:client_secret)) or in the request body as client_secret. The token endpoint requires authentication.

Success Response

FieldTypeDescription
id_token string Signed JWT containing user claims (authorization_code grant). EdDSA by default; RS256 when the client is registered with id_token_signed_response_alg: "RS256"
access_token string Short-lived bearer token containing svid and session_id, signed with the same algorithm as the client's id_token
refresh_token string Rotating refresh token bound to the managed Identity session
token_type string Always "Bearer"
expires_in integer Access-token lifetime in seconds (typically 900)
Refresh Token Rotation

Each successful refresh returns a new refresh_token. Persist it atomically before making the next refresh request; replaying an older token fails.

UserInfo Endpoint

GET /{account_id}/userinfo

Returns claims about the person the access token was issued for. Also accepts POST.

Present the access token as a Bearer credential: Authorization: Bearer <access_token> on either verb, or as an access_token form field on POST with Content-Type: application/x-www-form-urlencoded. The token is the authorization, so no client authentication is required. A token in the query string is not accepted, because URLs reach logs, referrers and browser history.

Response

FieldTypeDescription
sub string Always returned. The same value as the sub claim of the id_token issued for this session
name string profile scope. The person's full name, composed from the parts below
given_name string profile scope
middle_name string profile scope
family_name string profile scope
email string email scope
email_verified boolean email scope
phone_number string phone scope
phone_number_verified boolean phone scope

A claim is present only when the grant carried the scope that releases it and the person has a value for it. Claims are read when you call, not when the token was issued, so a name the person changed after signing in is reflected here rather than in the id_token they already hold.

What this endpoint does not return

A person may also record a title, a suffix, and any number of alternative names. OpenID Connect defines no standard claim for those, so they are never placed in a token or returned here. picture is likewise not a UserInfo claim: read it from the id_token.

Errors follow RFC 6750

A missing, malformed, expired or revoked token returns 401 with a WWW-Authenticate: Bearer challenge. A token whose grant lacks the openid scope returns 403 with error="insufficient_scope". The response never reveals whether a subject exists.

Introspection Endpoint

POST /{account_id}/introspect

Checks whether an access token is still active for its managed Identity session. Confidential clients only.

Request Body

ParameterTypeRequiredDescription
token string Required Access token returned by the token endpoint
client_id string Required Your registered client ID
client_secret string Optional Your client secret, when authenticating with client_secret_post. Use the Authorization: Basic header instead for client_secret_basic. One of the two is required.
Confidential clients only

/introspect accepts only client_secret_basic or client_secret_post (as advertised in introspection_endpoint_auth_methods_supported). A public client — one registered with token_endpoint_auth_method: "none", such as a browser SPA using PKCE — is rejected with 401 invalid_client. Per RFC 7662 an unauthenticated introspection endpoint is a token-scanning oracle, so there is no public-client path.

Active Response

FieldTypeDescription
active boolean true when signature, expiry, client, and managed session are valid
token_type string Always "access" for tokens issued by the token endpoint
svid string SparkVault ID for the person
session_id string Managed Identity session ID
sub string Access-token subject; equals the SVID
aud string Client ID audience
exp integer Access-token expiration time

Inactive, expired, revoked, or foreign-client tokens return { "active": false } with no other fields.

When to introspect

Validate JWT signatures locally with cached JWKS for ordinary requests. Use /introspect for sensitive mutations, account settings, admin/moderation actions, suspicious sessions, or short cached hard checks where immediate revocation matters.

Revocation Endpoint

POST /{account_id}/revoke

Revokes the managed Identity session identified by a refresh token or access token.

Request Body

ParameterTypeRequiredDescription
token string Required Current refresh token, or an access token for the session being signed out
client_id string Required Your registered client ID
token_type_hint string Optional Accepted per RFC 7009 but ignored: the server detects the token type itself, so the hint does not change behavior
Client Authentication Required

Include the client secret with HTTP Basic auth or client_secret in the request body for confidential clients.

Response

FieldTypeDescription
revoked boolean true when a same-client managed session was revoked. Invalid or foreign tokens return false with HTTP 200.

End Session (RP-Initiated Logout)

GET /{account_id}/end_session

Browser-navigated sign-out (OpenID Connect RP-Initiated Logout 1.0). Ends the RP session your id_token identifies, then returns the browser to your registered post-logout URI — after a brief interstitial where the person chooses whether to also end SparkVault-wide SSO. Also accepts POST.

Query Parameters

ParameterTypeRequiredDescription
id_token_hint string Optional The ID token issued to your client. An expired token is accepted as long as its signature verifies. Required for post_logout_redirect_uri to be honored and for targeted session revocation (via its session_id claim).
post_logout_redirect_uri string Optional Where to send the browser afterwards. Honored only when it exactly matches one of the client's registered post_logout_redirect_uris (registered when the client is created). An unregistered or absent value is ignored — the sign-out still completes and the browser lands on the SparkVault signed-out page.
state string Optional Opaque value echoed back on the post-logout redirect
SparkVault-wide sign-out is the person's choice

end_session ends your site's session immediately. Whether to also end the person's SparkVault single sign-on on that browser is confirmed with the person on an interstitial page — a customer site cannot silently sign someone out of every SparkVault-connected site. Navigate the browser top-level (not fetch/XHR): the sign-out state lives in cookies on the identity origin.

ID Token Claims

Two Token Formats

SparkVault Identity issues two different token formats depending on the integration method:

OIDC ID Tokens (this section): sub is the SVID. Email logins include email/email_verified; phone logins include phone_number/phone_number_verified.

SDK/Simple Mode Tokens: sub is also the SVID when the verification opened a managed session, with identity, identity_type, session_id, verified_at, and method. See the SDK section above for these claims.

The OIDC ID token is a JWT signed with Ed25519 (EdDSA) by default, or RS256 when the client is registered with id_token_signed_response_alg: "RS256". After verification, extract these claims:

Standard Claims

FieldTypeDescription
iss string Issuer URL: https://auth.sparkvault.com/{account_id}
sub string Subject identifier; equals the SVID
aud string Audience: your client_id
exp integer Expiration time (Unix timestamp)
iat integer Issued at time (Unix timestamp)
auth_time integer The real time the person authenticated (Unix timestamp): the completion of the sign-in ceremony for an interactive login, or the creation of the SparkVault SSO session for a silent SSO mint — never the time this token was issued. Compare it against your own freshness policy, or state that policy up front with max_age on the authorize request.
nonce string Nonce from authorization request (if provided)

Identity Claims

FieldTypeDescription
svid string Canonical SparkVault ID for the person
identity string Verified email or phone identity value
identity_type string "email" or "phone"
email string The verified email address, only for email identities
email_verified boolean True for email identities
phone_number string The verified E.164 phone number, only for phone identities
phone_number_verified boolean True for phone identities
amr string[] Every factor presented: ["passkey"], ["sparklink"], ["otp"], ["social:google"], ["sso"], ["saml:okta"], or a primary plus second factor like ["otp", "totp"] / ["otp", "recovery"]
acr string Authentication context class: urn:sparkvault:identity:1 (single factor) or urn:sparkvault:identity:mfa when a distinct second factor (authenticator app or recovery code) cleared the login
session_id string The managed Identity session this token was minted against — what end_session revokes when the token is presented as id_token_hint
picture string? Public avatar URL — the stable https://my.auth.sv/{svid}/avatar — present only while the person publishes a visible photo. The standard OIDC claim name (OIDC Core 1.0 §5.1), so client libraries that map picture to a profile image need no custom wiring. Its presence is the supported signal that a real photo exists, since the URL itself always renders. See auth.sv Public Avatars.
json Example Decoded ID Token Payload
{
  "iss": "https://auth.sparkvault.com/acc_example",
  "sub": "ing_019e66a4e27875f2822ede0e4f5d8792",
  "aud": "your-client-id",
  "exp": 1703980800,
  "iat": 1703977200,
  "auth_time": 1703977195,
  "nonce": "abc123",
  "svid": "ing_019e66a4e27875f2822ede0e4f5d8792",
  "session_id": "sess_1f4a9c2d7b3e48a0",
  "identity": "user@example.com",
  "identity_type": "email",
  "email": "user@example.com",
  "email_verified": true,
  "amr": ["passkey"],
  "acr": "urn:sparkvault:identity:1"
}

Public Avatar Lookup

Every person has one stable, unauthenticated avatar URL — the same URL the picture claim carries — and it always renders an image. You can also resolve an avatar from an email address or phone number you already hold, with no sign-in involved. It is a plain image URL: no SDK, no API key, no CORS.

html The whole integration
<!-- By SVID: the sub/svid claim from any SparkVault token -->
<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">

<!-- By identifier hash: lowercase hex SHA-256 of the normalized email or phone -->
<img src="https://my.auth.sv/b5fc85e55755f9e0d030a10ab4429b6b2944855f9a0d60077fe832becbc41d72/avatar"
     alt="" width="40" height="40" loading="lazy"
     referrerpolicy="no-referrer">

The selector is either an SVID (ing_ plus 32 lowercase hex characters) or exactly 64 lowercase hex characters, the SHA-256 of the normalized identifier. The full reference — the hashing rules, framework snippets, the CSP line, response and caching contracts, what the person controls, and how to debug a broken embed — lives on its own page:

auth.sv Public Avatars →

Simple Redirect Verification

For simpler integrations that don't need full OIDC, use the redirect verification flow. The user verifies their identity, and you receive a signed, opaque callback confirming the verification. No email or other PII travels in the callback.

The flow is session-based: the redirect targets, identity hint, context label, and state are committed server-side when the session is minted, so they never appear in URLs: not in browser history, not in Referer headers, not in access logs. The user-visible URL carries only an opaque session token.

Mint a Verify Session

POST /{account_id}/verify/session

Mint an opaque verify session and get back its verify URL. Internal, service-to-service only. External HTTP calls are rejected.

Request Body

ParameterTypeRequiredDescription
success_url string Required Post-verification redirect target (must be pre-registered)
failure_url string Optional Post-failure redirect target (defaults to success_url)
identity string Optional Pre-fill hint shown to the user
context string Optional Human-readable label rendered above the login form
state string Optional Opaque caller state echoed back in the callback
json Response
{
  "session_id": "nZk4tW8xQ2mVbC7dEfGh1A",
  "verify_url": "https://auth.sparkvault.com/{account_id}/verify?session=nZk4tW8xQ2mVbC7dEfGh1A",
  "expires_in_seconds": 900
}

This mint endpoint is internal (service-to-service): SparkVault surfaces that need an identity-gated hand-off (for example, identity-gated SparkLinks) mint sessions through it, so a third party can never pre-warm sessions for arbitrary success URLs. Sessions expire after 15 minutes.

Start Verification

GET /{account_id}/verify

Entry point for a minted verify session. Redirects to the hosted login page, then back to the session's success_url with signed callback parameters.

Query Parameters

ParameterTypeRequiredDescription
session string Required Opaque verify-session ID from the session mint. This is the only parameter. Everything else is read back server-side from the session.

Success Callback

On successful verification, the user is redirected to the session's success_url with these query parameters:

Callback Parameters

FieldTypeDescription
verified string "true" on success
session_id string Opaque verify-session ID. SparkVault resolves the verified identity from this row server-side; the raw identity never travels in the URL.
timestamp string Verification time (Unix timestamp)
nonce string Unique nonce for replay protection
account_id string Your tenant account ID
signature string Ed25519 signature over verified, session_id, timestamp, nonce, and account_id
state string Your state value (if provided)

Verify Callback Signature

javascript
import { ed25519 } from '@noble/curves/ed25519.js';

async function verifyCallback(params, accountId) {
  // Fetch the JWKS. It carries an Ed25519 key and an RSA key; the callback
  // signature is always Ed25519, and it carries no header to name a kid, so
  // select the key by its type rather than by array position.
  const jwksResponse = await fetch(
    `https://auth.sparkvault.com/${accountId}/.well-known/jwks.json`
  );
  const jwks = await jwksResponse.json();
  const jwk = jwks.keys.find(k => k.kty === 'OKP' && k.crv === 'Ed25519');
  if (!jwk) throw new Error('No Ed25519 key in JWKS');
  const publicKeyBytes = base64urlDecode(jwk.x);

  // Check timestamp is recent (5 minute window)
  const timestamp = parseInt(params.timestamp, 10);
  const now = Math.floor(Date.now() / 1000);
  if (timestamp < now - 300 || timestamp > now + 60) {
    throw new Error('Timestamp out of range');
  }

  // Rebuild the exact string the server signed (field order matters).
  const dataToVerify = [
    'verified=true',
    `session_id=${params.session_id}`,
    `timestamp=${params.timestamp}`,
    `nonce=${params.nonce}`,
    `account_id=${params.account_id}`
  ].join('&');

  // Decode and verify signature
  const signatureBytes = base64urlDecode(params.signature);
  const isValid = ed25519.verify(
    signatureBytes,
    new TextEncoder().encode(dataToVerify),
    publicKeyBytes
  );

  if (!isValid) throw new Error('Invalid signature');

  // The callback carries no PII. A valid signature confirms verification for
  // the session you initiated; SparkVault resolves the verified identity
  // server-side, so you never receive the raw email here.
  return {
    verified: true,
    sessionId: params.session_id,
    method: 'simple_redirect'
  };
}

Failure Callback

On failure, the user is redirected to the session's failure_url with:

Failure Parameters

FieldTypeDescription
verified string "false"
error string Error code (e.g., "access_denied", "expired_link")
error_description string Human-readable error message
state string Your state value (if provided)

Server-to-Server API (Direct OTP)

For applications that want to maintain their own authentication UI, you can call the OTP endpoints directly. This allows you to build a custom login experience while using SparkVault Identity for secure email/SMS verification.

When to Use Direct API

Use the direct OTP API when you want full control over your authentication UI. Use the OIDC flow when you want the convenience of a hosted login page with multiple auth methods.

Send Verification Code

POST /{account_id}/otp/send

Send a 6-digit verification code to an email address or phone number.

Request Body

ParameterTypeRequiredDescription
recipient string Required Email address or phone number (E.164 format for SMS/voice)
method string Required "email", "sms", "voice", or "sparklink"
connection_id string Optional Required when method is "sparklink": WebSocket connection ID for token delivery
ttl_minutes integer Optional Code lifetime in minutes (clamped to 1-60)Default: 15
bash Example Request
curl -X POST 'https://auth.sparkvault.com/{account_id}/otp/send' \
  -H 'Content-Type: application/json' \
  -d '{
    "recipient": "user@example.com",
    "method": "email"
  }'
json Success Response
{
  "success": true,
  "kindling": "kdl_abc123...",
  "expires_at": 1704067800,
  "method": "email"
}

method echoes the request's method value ("email", "sms", or "voice").

Kindling Token

The kindling token is required when verifying the code. It binds the verification attempt to the original send request. Pass it along with the code to the verify endpoint.

Verify Code

POST /{account_id}/otp/verify

Verify the code entered by the user and receive a signed JWT token.

Request Body

ParameterTypeRequiredDescription
recipient string Required The email/phone that received the code
pin string Required 6-digit verification code
kindling string Required Kindling token from the send response
bash Example Request
curl -X POST 'https://auth.sparkvault.com/{account_id}/otp/verify' \
  -H 'Content-Type: application/json' \
  -d '{
    "recipient": "user@example.com",
    "pin": "123456",
    "kindling": "kdl_abc123..."
  }'
json Success Response
{
  "verified": true,
  "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...",
  "identity": "user@example.com",
  "identity_type": "email",
  "method": "otp",
  "svid": "ing_019e66a4e27875f2822ede0e4f5d8792",
  "session_id": "sess_abc123",
  "refresh_token": "rt_...",
  "recommendations": [
    { "type": "passkey" }
  ]
}

Beyond the token, the default verified response includes the opened managed session (svid, session_id, refresh_token) and recommendations: ordered post-login nudges such as a passkey upsell or a backup-identifier prompt ({ "type": "backup_identifier", "missing_type": "phone" }).

JWT Token

The returned token is a JWT signed with Ed25519. You can verify it using the JWKS endpoint exactly like ID tokens. The token contains the verified identity and can be used as proof of identity.

Server-to-Server Example (Node.js)

javascript
const IDENTITY_URL = 'https://auth.sparkvault.com';
const ACCOUNT_ID = 'acc_youraccountid';

// Step 1: Send verification code
async function sendVerificationCode(email) {
  const response = await fetch(`${IDENTITY_URL}/${ACCOUNT_ID}/otp/send`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      recipient: email,
      method: 'email'
    })
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to send code');
  }

  return response.json();
  // { success: true, kindling: "kdl_...", expires_at: 1234567890, method: "email" }
}

// Step 2: Verify code entered by user (include kindling from step 1)
async function verifyCode(email, pin, kindling) {
  const response = await fetch(`${IDENTITY_URL}/${ACCOUNT_ID}/otp/verify`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      recipient: email,
      pin,
      kindling  // Required - from send response
    })
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Verification failed');
  }

  return response.json();
  // { verified, token, identity, identity_type, method,
  //   svid, session_id, refresh_token, recommendations }
}

// Step 3: Verify the returned JWT using JWKS
import * as jose from 'jose';

async function verifyToken(token) {
  const jwksUrl = `${IDENTITY_URL}/${ACCOUNT_ID}/.well-known/jwks.json`;
  const JWKS = jose.createRemoteJWKSet(new URL(jwksUrl));

  const { payload } = await jose.jwtVerify(token, JWKS, {
    issuer: `${IDENTITY_URL}/${ACCOUNT_ID}`,
    audience: ACCOUNT_ID,
    algorithms: ['EdDSA']
  });

  return payload;  // { identity, identity_type, method, verified_at, ... }
}

Server-to-Server Example (Python)

python
import requests

IDENTITY_URL = 'https://auth.sparkvault.com'
ACCOUNT_ID = 'acc_youraccountid'

def send_verification_code(email: str) -> dict:
    """Send a verification code to the email address."""
    response = requests.post(
        f'{IDENTITY_URL}/{ACCOUNT_ID}/otp/send',
        json={
            'recipient': email,
            'method': 'email'
        }
    )
    response.raise_for_status()
    return response.json()
    # Returns: {'success': True, 'kindling': 'kdl_...', 'expires_at': ..., 'method': 'email'}

def verify_code(email: str, pin: str, kindling: str) -> dict:
    """Verify the code and get a signed JWT."""
    response = requests.post(
        f'{IDENTITY_URL}/{ACCOUNT_ID}/otp/verify',
        json={
            'recipient': email,
            'pin': pin,
            'kindling': kindling  # Required - from send response
        }
    )
    response.raise_for_status()
    return response.json()
    # Returns: {'verified': True, 'token': '...', 'identity': '...', 'identity_type': '...',
    #           'method': '...', 'svid': '...', 'session_id': '...', 'refresh_token': '...',
    #           'recommendations': [...]}

Incorrect Code

A wrong code (before it expires) is not an error. POST /otp/verify returns HTTP 200 with verified: false and the same kindling so the user can retry. Once backoff applies, a retry_after field (Unix seconds) tells you when the next attempt is allowed.

json Incorrect Code Response (HTTP 200)
{
  "verified": false,
  "kindling": "kdl_abc123...",
  "expires_at": 1704067800,
  "retry_after": 1704067830
}

Error Responses

Error Responses

StatusCodeDescription
400 invalid_request Missing or invalid required parameter
400 expired_code Verification code has expired (default 15 min TTL)
400 rate_limited Too many attempts. Surfaces as a validation error whose message includes the wait time, not a 429.
500 send_failed Failed to send verification code

Passkey API (WebAuthn)

Passkeys provide phishing-resistant, biometric authentication using the WebAuthn standard. Users can register a passkey (fingerprint, Face ID, Windows Hello) and use it for fast, secure authentication without codes or links.

SDK Handles This

The JavaScript SDK handles the full passkey flow automatically. These endpoints are documented for custom implementations or server-to-server integrations.

Check Passkey Exists

POST /{account_id}/passkey/check

Resolve an email or phone to an SVID and report whether that person has passkeys. Passkeys live on the identity ingot, not on the identifier.

Request Body

ParameterTypeRequiredDescription
email string Optional Email address to check. Exactly one of email or phone must be provided.
phone string Optional Phone number in E.164 format. Exactly one of email or phone must be provided.
bash Example Request
curl -X POST 'https://auth.sparkvault.com/{account_id}/passkey/check' \
  -H 'Content-Type: application/json' \
  -d '{ "email": "user@example.com" }'
json Response
{
  "identity_valid": true,
  "has_passkey": true
}

Start Registration

POST /{account_id}/passkey/register

Start passkey registration. Returns WebAuthn options for navigator.credentials.create() plus a one-time session.

Request Body

ParameterTypeRequiredDescription
email string Optional Freshly verified email whose SVID receives the new passkey (mutually exclusive with phone)
phone string Optional Freshly verified phone whose SVID receives the new passkey (mutually exclusive with email)
registration_token string Required Fresh token from a successful verification for the same identifier
device_name string Optional Optional label for the new passkey (shown in passkey management)
json Response
{
  "options": {
    "challenge": "base64url-encoded-challenge",
    "rp": { "name": "SparkVault Identity", "id": "sparkvault.com" },
    "user": { "id": "base64url-user-id", "name": "user@example.com", "displayName": "User" },
    "pubKeyCredParams": [
      { "type": "public-key", "alg": -7 },
      { "type": "public-key", "alg": -257 }
    ],
    "timeout": 60000,
    "attestation": "none",
    "excludeCredentials": [
      { "type": "public-key", "id": "base64url-existing-credential-id" }
    ],
    "authenticatorSelection": {
      "residentKey": "required",
      "requireResidentKey": true,
      "userVerification": "required"
    }
  },
  "session": {
    "sessionId": "9f2c4e8a1b3d5f7a9c0e2468acefdb13579bdf02468ace13579bdf024680acef",
    "challenge": "base64url-encoded-challenge",
    "identity": "user@example.com",
    "identityType": "email",
    "deviceName": null,
    "rpId": "sparkvault.com"
  }
}

pubKeyCredParams supports ES256 (-7) and RS256 (-257). excludeCredentials lists the person's existing passkeys so the authenticator won't double-register. User verification (biometric/PIN) is required and enforced server-side on completion. session.sessionId is a 64-character hex value; the authoritative session state is stored server-side and is one-time use.

Complete Registration

POST /{account_id}/passkey/register/complete

Complete passkey registration with the credential from navigator.credentials.create().

Request Body

ParameterTypeRequiredDescription
session object Required The session object from the register start response (must include sessionId; consumed atomically, one-time use)
credential object Required PublicKeyCredential from navigator.credentials.create()
auth_request_id string Optional OIDC auth request ID (for immediate login after registration)
simple_mode object Optional Simple redirect completion context
json Success Response
{
  "verified": true,
  "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...",
  "identity": "user@example.com",
  "identity_type": "email",
  "method": "passkey",
  "svid": "ing_019e66a4e27875f2822ede0e4f5d8792",
  "session_id": "sess_abc123",
  "refresh_token": "rt_...",
  "recommendations": []
}

Start Authentication

POST /{account_id}/passkey/verify

Start passkey authentication. Supply an optional email or phone to scope the ceremony to that person's passkeys (the response includes allowCredentials so the authenticator offers only the matching credential); omit it for a discoverable ceremony. Either way the selected credential returns a WebAuthn userHandle containing the SVID, which completion verifies against.

Request Body

ParameterTypeRequiredDescription
email string Optional Scope the ceremony to this email's passkeys (mutually exclusive with phone)
phone string Optional Scope the ceremony to this phone's passkeys, E.164 (mutually exclusive with email)
action object Optional Optional action descriptor (e.g. { amount, recipient, nonce }) to bind the biometric ceremony to a specific action. Canonicalized and hashed server-side at start; mutually exclusive with action_hash.
action_hash string Optional Optional pre-computed 64-character hex SHA-256 of the action (e.g. a document content hash). Mutually exclusive with action.
auth_request_id string Optional OIDC auth request ID when completing an authorization-code flow
simple_mode object Optional Simple redirect completion context
json Response
{
  "options": {
    "challenge": "base64url-encoded-challenge",
    "timeout": 60000,
    "rpId": "sparkvault.com",
    "userVerification": "required",
    "allowCredentials": [
      { "type": "public-key", "id": "base64url-credential-id" }
    ]
  },
  "session": {
    "sessionId": "9f2c4e8a1b3d5f7a9c0e2468acefdb13579bdf02468ace13579bdf024680acef",
    "challenge": "base64url-encoded-challenge",
    "rpId": "sparkvault.com"
  }
}
Action-Bound Approvals

When action or action_hash is supplied, the hash is stored on the one-shot server-side session at start and is never re-sent on complete. The action the biometric authorized cannot be swapped mid-ceremony. The resulting token carries an action_hash claim, making it a signed approval receipt: "identity S approved action H at time T", not merely "S verified".

Complete Authentication

POST /{account_id}/passkey/verify/complete

Complete passkey authentication with the assertion from navigator.credentials.get().

Request Body

ParameterTypeRequiredDescription
session object Required The session object from the verify start response (must include sessionId; consumed atomically, one-time use)
credential object Required PublicKeyCredential from navigator.credentials.get()
auth_request_id string Optional OIDC auth request ID
simple_mode object Optional Simple redirect completion context
json Success Response
{
  "verified": true,
  "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...",
  "identity": "user@example.com",
  "identity_type": "email",
  "method": "passkey",
  "svid": "ing_019e66a4e27875f2822ede0e4f5d8792",
  "session_id": "sess_abc123",
  "refresh_token": "rt_...",
  "recommendations": []
}

Passkey Management

Authenticated endpoints for managing a person's passkeys. Both require a valid Identity JWT from a recent authentication in the Authorization: Bearer header, and operate on the identity's passkeys across the whole platform (passkeys are SVID-scoped under a single global RP, not per tenant).

GET /{account_id}/passkey/list

List the authenticated person's passkeys. Requires Authorization: Bearer <jwt>.

json Response
{
  "passkeys": [
    {
      "credential_id": "base64url-credential-id",
      "device_name": "MacBook Pro",
      "created_at": 1703977200,
      "last_used": 1704067800,
      "backup_eligible": true,
      "backup_state": true
    }
  ],
  "count": 1
}
DELETE /{account_id}/passkey/:credential_id

Remove a passkey. Requires Authorization: Bearer <jwt>. Sends a passkey-removed notification to the identity's email or phone.

Path Parameters

ParameterTypeRequiredDescription
credential_id string Required Base64url-encoded credential ID to delete
json Response
{
  "success": true,
  "credential_id": "base64url-credential-id"
}

Passkey Popup

GET /{account_id}/passkey/popup

Serves the passkey popup page for cross-origin WebAuthn ceremonies.

Used internally by the SDK for cross-origin passkey flows. Opens in a popup window to handle the WebAuthn ceremony on the Identity domain.

Two-Factor Authentication (TOTP)

People can enroll an authenticator app (TOTP) as a second factor in the auth.sv portal. When 2FA is enabled, Identity holds every non-passkey login at a second-factor gate after the primary factor succeeds: no session is opened, no tokens are minted, and the one-shot OIDC/simple-mode context is left unconsumed until the second factor clears. Passkeys are phishing-resistant MFA on their own (possession + biometric), so a passkey login never triggers the gate.

SDK and API callers receive a JSON challenge from the primary verify call instead of the verified result:

json Second-Factor Challenge
{
  "verified": false,
  "second_factor_required": true,
  "ticket": "opaque-64-char-hex-ticket",
  "methods": ["authenticator", "recovery_code"],
  "identity": "user@example.com",
  "identity_type": "email"
}

Top-level browser logins that cannot consume a JSON body (social, SAML, SparkLink) are redirected to the hosted code-entry page instead. The ticket is opaque, short-lived, one-shot, and attempt-limited. Too many wrong codes retires it and the person must sign in again.

Hosted Code-Entry Page

GET /{account_id}/second-factor

Render the hosted second-factor code-entry page for top-level browser logins.

Query Parameters

ParameterTypeRequiredDescription
ticket string Required Pending-login ticket minted at the second-factor gate

Verify Second Factor

POST /{account_id}/second-factor/verify

Submit the authenticator code (or a recovery code) and finish the held login.

Request Body

ParameterTypeRequiredDescription
ticket string Required Pending-login ticket from the challenge
code string Required 6-digit authenticator code, or a single-use recovery code (anything that isn't a 6-digit code is treated as a recovery code)

On success, the response is the same completion shape as a primary login (token, managed session, recommendations). Tokens minted through the gate carry both factors in amr (e.g. ["otp", "totp"] or ["otp", "recovery"]) and acr: urn:sparkvault:identity:mfa.

Social Login API

Social login allows users to authenticate using their existing accounts from Google, Apple, Microsoft, GitHub, Facebook, or LinkedIn. The Identity Product handles the full OAuth flow.

Initiate Social Login

GET /{account_id}/social/:provider

Redirects user to the social provider's OAuth consent page.

Path Parameters

ParameterTypeRequiredDescription
provider string Required Provider ID: google, apple, microsoft, github, facebook, linkedin

Query Parameters

ParameterTypeRequiredDescription
redirect_uri string Optional Registered return URL for direct SDK token redirects. Required unless auth_request_id or simple_mode is provided, and must be registered for the account.
state string Optional Opaque value echoed on direct SDK token redirects for CSRF protection
auth_request_id string Optional OIDC hosted-login auth request to complete with an authorization code
simple_mode string Optional JSON simple-verify context; session-backed flows send only session_id
client_id string Optional OIDC client ID when relaying an authorization-code flow
nonce string Optional OIDC nonce relayed into the resulting ID token
code_challenge string Optional PKCE challenge relayed for the OIDC flow
code_challenge_method string Optional PKCE method (S256) accompanying code_challenge
opener_origin string Optional Popup opener origin for postMessage result delivery
text Example
GET https://auth.sparkvault.com/{account_id}/social/google?redirect_uri=https://yourapp.com/callback&state=abc123

→ Redirects to Google OAuth consent page
→ After consent, redirects to /social/google/callback
→ Finally redirects to your redirect_uri with #token=...&state=abc123

Social Login Callback

GET /social/:provider/callback

Handles the OAuth callback from the social provider. Validates the token and completes the stored SparkVault login flow.

This endpoint is called by the social provider after user consent. You don't call it directly. On success, SparkVault completes the stored OIDC, simple-verify, portal-link, or SDK redirect flow.

Supported Providers

Provider ID Returns
GooglegoogleEmail, verified status
AppleappleEmail (may be private relay)
MicrosoftmicrosoftEmail, verified status
GitHubgithubPrimary email
FacebookfacebookEmail
LinkedInlinkedinEmail

Enterprise SSO (SAML)

SAML integration allows enterprise customers to use their existing identity providers (Okta, Azure AD/Entra, OneLogin, Ping Identity, JumpCloud) for single sign-on.

Enterprise Feature

SAML integration requires configuration of your IdP. Contact support to set up SAML for your organization.

Initiate SAML Login

GET /{account_id}/saml/:provider

Redirects user to the configured SAML Identity Provider for authentication.

Path Parameters

ParameterTypeRequiredDescription
provider string Required Provider ID: okta, entra, onelogin, ping, jumpcloud

Query Parameters

ParameterTypeRequiredDescription
redirect_uri string Optional Return URL for direct SDK token redirects. Optional: the SAML flow otherwise completes through auth_request_id or the server-side relay-state context.
state string Optional Opaque value for CSRF protection

SAML Assertion Consumer Service (ACS)

POST /{account_id}/saml/:provider/acs

Receives SAML assertions from the IdP. Validates the assertion and redirects to your app with a signed JWT.

This endpoint is called by your SAML IdP after authentication. Configure your IdP to POST assertions to this URL. On success, the user is redirected to your redirect_uri with a signed token.

Supported SAML Providers

Provider ID Notes
OktaoktaFull SAML 2.0 support
Microsoft Entra ID (Azure AD)entraFull SAML 2.0 support
OneLoginoneloginFull SAML 2.0 support
Ping IdentitypingFull SAML 2.0 support
JumpCloudjumpcloudFull SAML 2.0 support

IdP Configuration

text SparkVault SAML Service Provider Metadata
Entity ID: https://auth.sparkvault.com/{account_id}
ACS URL:   https://auth.sparkvault.com/{account_id}/saml/{provider}/acs
Binding:   HTTP-POST
NameID:    Email address (required)

Hosted Login Page

The hosted login page provides a complete, branded authentication experience with every method you have enabled (passkey, OTP, SparkLink, social, SAML). You don't link users to it directly with your own parameters. People arrive through one of the two entry points, each of which hands the page an opaque context token:

  • OIDC: /authorize validates your request and redirects here with auth_request=<id>.
  • Simple verify: /verify?session=<id> redirects here with mode=simple&session=<id>.
GET /{account_id}

Serves the hosted login page with all enabled authentication methods.

Query Parameters

ParameterTypeRequiredDescription
auth_request string Optional OIDC auth request ID, set by the /authorize redirect
mode string Optional "simple" when arriving from the simple-verify entry point
session string Optional Opaque verify-session ID, required when mode=simple; every other field (redirect URLs, identity hint, context label, state) is loaded server-side from the session
text Example
https://auth.sparkvault.com/{account_id}?auth_request=req_abc123        ← from /authorize
https://auth.sparkvault.com/{account_id}?mode=simple&session=nZk4tW8x   ← from /verify

→ Shows the branded login page with all enabled methods
→ User chooses a method and verifies
→ The stored OIDC or simple-verify flow completes (code callback or signed redirect)

There are no direct redirect_uri, email, phone, or methods query parameters and no standalone token-callback flow. Email pre-fill comes from the OIDC auth request's login hint or from the identity committed when the verify session was minted.

Custom Domains

You can use a custom domain (e.g., login.yourcompany.com) for white-label hosted login. The hosted page respects your organization branding settings.

SDK Configuration

The SDK configuration endpoint returns the available authentication methods for your account. The JavaScript SDK calls this automatically on initialization. Requests must originate from a page on one of the account's verified company domains (the Origin check that also guards the other SDK endpoints).

GET /{account_id}/config

Returns SDK configuration including enabled authentication methods and branding.

json Response
{
  "data": {
    "accountId": "acc_example",
    "branding": {
      "companyName": "Your Company",
      "logoLight": "https://yourcompany.com/logo-light.png",
      "logoDark": "https://yourcompany.com/logo-dark.png",
      "themeMode": "light"
    },
    "allowedIdentityTypes": ["email", "phone"],
    "methods": [
      "passkey",
      "otp_email",
      "otp_sms",
      "sparklink",
      "social_google",
      "social_apple"
    ],
    "sso": { "enabled": true, "forced": false },
    "wsDomain": "ws.sparkvault.com"
  }
}

methods can also include the enterprise SSO IDs enterprise_okta, enterprise_entra, enterprise_onelogin, enterprise_ping, and enterprise_jumpcloud. sso reports the account's cross-domain SSO posture (see the Cross-Domain SSO section): enabled — on by default — governs whether the account participates in silent SSO and the sign-in popup's one-click “Continue as …” and account chooser, and is enforced server-side (it is account configuration, not an SDK setting); forced is reserved for accounts where the hosted page is the only sign-in surface. The response is cacheable: Cache-Control: public, max-age=300, stale-while-revalidate=600.

Server-Side Examples (OIDC)

Node.js / Express

javascript
import express from 'express';
import { ed25519 } from '@noble/curves/ed25519.js';

const app = express();

// Configuration
const IDENTITY_URL = 'https://auth.sparkvault.com/acc_youraccountid';
const CLIENT_ID = 'your-client-id';
const CLIENT_SECRET = process.env.IDENTITY_CLIENT_SECRET;
const REDIRECT_URI = 'https://your-app.com/auth/callback';

// JWKS cache. The set carries an Ed25519 key and an RSA key, so always
// select by the kid in the token header — never by array position.
let jwksCache = null;
let jwksCacheExpiry = 0;

async function getJwks(forceRefresh = false) {
  if (!forceRefresh && jwksCache && Date.now() < jwksCacheExpiry) {
    return jwksCache;
  }
  const res = await fetch(`${IDENTITY_URL}/.well-known/jwks.json`);
  jwksCache = (await res.json()).keys;
  jwksCacheExpiry = Date.now() + 5 * 60 * 1000; // 5 min
  return jwksCache;
}

async function getPublicKey(kid) {
  let keys = await getJwks();
  let jwk = keys.find(k => k.kid === kid);
  if (!jwk) {
    keys = await getJwks(true);        // unknown kid: re-fetch once
    jwk = keys.find(k => k.kid === kid);
  }
  if (!jwk) throw new Error(`No JWKS key for kid ${kid}`);
  // This example verifies the default EdDSA signing. A client registered with
  // id_token_signed_response_alg: "RS256" gets the RSA key here instead.
  if (jwk.alg !== 'EdDSA') throw new Error(`Unexpected signing algorithm ${jwk.alg}`);
  return base64urlDecode(jwk.x);
}

// Login route - redirect to Identity
app.get('/login', async (req, res) => {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  const state = crypto.randomUUID();
  const nonce = crypto.randomUUID();

  // Store in session
  req.session.pkce = { codeVerifier, state, nonce };

  const authUrl = new URL(`${IDENTITY_URL}/authorize`);
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('client_id', CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
  authUrl.searchParams.set('scope', 'openid email');
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('nonce', nonce);
  authUrl.searchParams.set('code_challenge', codeChallenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');

  res.redirect(authUrl.toString());
});

// Callback route - exchange code for token
app.get('/auth/callback', async (req, res) => {
  const { code, state, error } = req.query;
  const { codeVerifier, state: savedState, nonce } = req.session.pkce || {};

  // Handle errors
  if (error) {
    return res.redirect(`/login?error=${error}`);
  }

  // Validate state
  if (state !== savedState) {
    return res.status(400).send('Invalid state');
  }

  // Exchange code for tokens
  const tokenRes = await fetch(`${IDENTITY_URL}/token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')
    },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: codeVerifier
    })
  });

  const { id_token, access_token, refresh_token } = await tokenRes.json();

  // Verify ID token
  const claims = await verifyIdToken(id_token, nonce);

  // Clear PKCE data
  delete req.session.pkce;

  // BFF boundary: keep token material server-side or in encrypted/signed
  // HttpOnly, Secure, SameSite=Lax cookies. Browser JS should never see it.
  req.session.identity = { accessToken: access_token, refreshToken: refresh_token };
  req.session.user = {
    svid: claims.svid || claims.sub,
    identity: claims.identity || claims.email || claims.phone_number
  };

  res.redirect('/dashboard');
});

app.post('/logout', async (req, res) => {
  const refreshToken = req.session.identity?.refreshToken;
  if (refreshToken) {
    await fetch(`${IDENTITY_URL}/revoke`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')
      },
      body: JSON.stringify({
        client_id: CLIENT_ID,
        token: refreshToken
      })
    });
  }
  req.session.destroy(() => res.sendStatus(204));
});

async function verifyIdToken(idToken, expectedNonce) {
  const [headerB64, payloadB64, signatureB64] = idToken.split('.');
  const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
  const publicKey = await getPublicKey(header.kid);

  // Verify signature
  const signedData = `${headerB64}.${payloadB64}`;
  const signature = base64urlDecode(signatureB64);
  const isValid = ed25519.verify(
    signature,
    new TextEncoder().encode(signedData),
    publicKey
  );

  if (!isValid) throw new Error('Invalid signature');

  const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());

  // Validate claims
  if (payload.iss !== IDENTITY_URL) throw new Error('Invalid issuer');
  if (payload.nonce !== expectedNonce) throw new Error('Invalid nonce');
  if (payload.exp < Math.floor(Date.now() / 1000)) throw new Error('Token expired');
  if (payload.aud !== CLIENT_ID) throw new Error('Invalid audience');

  return payload;
}

Python / Flask

python
import os
import base64
import hashlib
import secrets
from flask import Flask, redirect, request, session
import requests
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError

app = Flask(__name__)
app.secret_key = os.environ['FLASK_SECRET_KEY']

IDENTITY_URL = 'https://auth.sparkvault.com/acc_youraccountid'
CLIENT_ID = 'your-client-id'
CLIENT_SECRET = os.environ['IDENTITY_CLIENT_SECRET']
REDIRECT_URI = 'https://your-app.com/auth/callback'

# JWKS cache. The set carries an Ed25519 key and an RSA key, so always
# select by the kid in the token header — never by list position.
_jwks_cache = {'keys': None, 'expires': 0}

def get_jwks(force_refresh=False):
    import time
    if not force_refresh and _jwks_cache['keys'] and time.time() < _jwks_cache['expires']:
        return _jwks_cache['keys']

    response = requests.get(f'{IDENTITY_URL}/.well-known/jwks.json', timeout=5)
    response.raise_for_status()
    _jwks_cache['keys'] = response.json()['keys']
    _jwks_cache['expires'] = time.time() + 300  # 5 min
    return _jwks_cache['keys']

def get_public_key(kid):
    jwk = next((k for k in get_jwks() if k.get('kid') == kid), None)
    if jwk is None:
        # Unknown kid: re-fetch once before giving up.
        jwk = next((k for k in get_jwks(force_refresh=True) if k.get('kid') == kid), None)
    if jwk is None:
        raise ValueError(f'No JWKS key for kid {kid}')
    # This example verifies the default EdDSA signing. A client registered with
    # id_token_signed_response_alg: "RS256" gets the RSA key here instead.
    if jwk.get('alg') != 'EdDSA':
        raise ValueError(f"Unexpected signing algorithm {jwk.get('alg')}")

    key_b64 = jwk['x']
    # Add padding if needed
    padding = 4 - (len(key_b64) % 4)
    if padding != 4:
        key_b64 += '=' * padding
    return base64.urlsafe_b64decode(key_b64)

def base64url_encode(data):
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii')

def generate_pkce():
    code_verifier = secrets.token_urlsafe(32)
    code_challenge = base64url_encode(
        hashlib.sha256(code_verifier.encode()).digest()
    )
    return code_verifier, code_challenge

@app.route('/login')
def login():
    code_verifier, code_challenge = generate_pkce()
    state = secrets.token_urlsafe(16)
    nonce = secrets.token_urlsafe(16)

    session['pkce'] = {
        'code_verifier': code_verifier,
        'state': state,
        'nonce': nonce
    }

    auth_url = (
        f'{IDENTITY_URL}/authorize'
        f'?response_type=code'
        f'&client_id={CLIENT_ID}'
        f'&redirect_uri={REDIRECT_URI}'
        f'&scope=openid%20email'
        f'&state={state}'
        f'&nonce={nonce}'
        f'&code_challenge={code_challenge}'
        f'&code_challenge_method=S256'
    )
    return redirect(auth_url)

@app.route('/auth/callback')
def callback():
    code = request.args.get('code')
    state = request.args.get('state')
    error = request.args.get('error')

    if error:
        return redirect(f'/login?error={error}')

    pkce = session.get('pkce', {})
    if state != pkce.get('state'):
        return 'Invalid state', 400

    # Exchange code
    credentials = base64.b64encode(
        f'{CLIENT_ID}:{CLIENT_SECRET}'.encode()
    ).decode()

    response = requests.post(
        f'{IDENTITY_URL}/token',
        headers={
            'Content-Type': 'application/json',
            'Authorization': f'Basic {credentials}'
        },
        json={
            'grant_type': 'authorization_code',
            'code': code,
            'redirect_uri': REDIRECT_URI,
            'client_id': CLIENT_ID,
            'code_verifier': pkce['code_verifier']
        }
    )

    token_response = response.json()
    id_token = token_response['id_token']
    claims = verify_id_token(id_token, pkce['nonce'])

    session.pop('pkce', None)
    # BFF boundary: keep token material server-side or in encrypted/signed
    # HttpOnly, Secure, SameSite=Lax cookies. Browser JS should never see it.
    session['identity'] = {
        'access_token': token_response['access_token'],
        'refresh_token': token_response['refresh_token']
    }
    session['user'] = {
        'svid': claims.get('svid') or claims['sub'],
        'identity': claims.get('identity') or claims.get('email') or claims.get('phone_number')
    }

    return redirect('/dashboard')

@app.route('/logout', methods=['POST'])
def logout():
    refresh_token = session.get('identity', {}).get('refresh_token')
    if refresh_token:
        credentials = base64.b64encode(
            f'{CLIENT_ID}:{CLIENT_SECRET}'.encode()
        ).decode()
        requests.post(
            f'{IDENTITY_URL}/revoke',
            headers={
                'Content-Type': 'application/json',
                'Authorization': f'Basic {credentials}'
            },
            json={
                'client_id': CLIENT_ID,
                'token': refresh_token
            },
            timeout=5
        )
    session.clear()
    return '', 204

def verify_id_token(id_token, expected_nonce):
    import json
    import time

    parts = id_token.split('.')
    header_b64, payload_b64, signature_b64 = parts

    # Decode header (for the kid) and payload
    def _b64(segment):
        padding = 4 - (len(segment) % 4)
        if padding != 4:
            segment += '=' * padding
        return base64.urlsafe_b64decode(segment)

    header = json.loads(_b64(header_b64))
    payload = json.loads(_b64(payload_b64))

    # Verify signature with the key the header names
    public_key = get_public_key(header['kid'])
    verify_key = VerifyKey(public_key)

    signed_data = f'{parts[0]}.{parts[1]}'.encode()
    signature = _b64(signature_b64)

    try:
        verify_key.verify(signed_data, signature)
    except BadSignatureError:
        raise ValueError('Invalid signature')

    # Validate claims
    if payload['iss'] != IDENTITY_URL:
        raise ValueError('Invalid issuer')
    if payload['nonce'] != expected_nonce:
        raise ValueError('Invalid nonce')
    if payload['exp'] < time.time():
        raise ValueError('Token expired')
    if payload['aud'] != CLIENT_ID:
        raise ValueError('Invalid audience')

    return payload

Error Handling

Authorization Errors

Request-validation failures at /authorize (missing or malformed parameters, a response_type other than code, a scope without openid, or an unregistered client or redirect_uri) return an HTTP 400 error response and never redirect. Only errors reached after the request validates, namely user cancellation and an unsatisfiable prompt=none, are delivered as query parameters on your redirect_uri.

Error Responses

StatusCodeDescription
400 invalid_request Missing or malformed parameter. Returned directly, not redirected.
400 unsupported_response_type response_type other than "code". Returned directly.
400 invalid_scope Scope missing "openid" or otherwise unsupported. Returned directly.
400 invalid_client client_id or redirect_uri not registered for this client. Returned directly.
302 access_denied User denied consent or cancelled. Delivered on redirect_uri.
302 login_required prompt=none could not be satisfied silently. Delivered on redirect_uri.
302 account_selection_required prompt=none with several accounts signed in — silent selection needs a single unambiguous candidate. Delivered on redirect_uri.
302 invalid_request prompt=none combined with any other prompt value — the combination asks for both no UI and a UI. Delivered on redirect_uri.

Token Endpoint Errors

Error Responses

StatusCodeDescription
400 invalid_request Missing required parameter
400 unsupported_grant_type grant_type must be "authorization_code" or "refresh_token"
400 invalid_grant Code is invalid, expired, or already used
401 invalid_client Client authentication failed
429 rate_limited Too many requests
json Token Error Response
{
  "error": "invalid_grant",
  "error_description": "Authorization code is invalid or expired"
}

Security Best Practices

  • Always use PKCE: The S256 code challenge is mandatory. Never skip PKCE, even for confidential clients.
  • Validate state parameter: Compare the returned state to your stored value to prevent CSRF attacks.
  • Verify all ID token claims: Check issuer, audience, expiration, and nonce. Don't trust tokens without full validation.
  • Use nonce for replay protection: Generate a unique nonce per request and verify it in the ID token.
  • Keep client_secret server-side: Never expose your client secret in frontend code or version control.
  • Use HTTPS redirect URIs: All registered redirect URIs must use HTTPS with no insecure origin exceptions.
  • Cache JWKS appropriately: Cache for 5-10 minutes, but re-fetch if you encounter an unknown key ID.
  • Use SVID as the app identity key: Store your product profile, roles, preferences, uploads, and moderation state under svid. Do not use email, phone, or a hash of either as the canonical account link.
  • Use a BFF for browser sessions: Exchange codes server-side and store refresh/session material only in encrypted or signed HttpOnly, Secure, SameSite=Lax cookies. Browser JavaScript should never see refresh tokens.
  • Validate locally, introspect selectively: Validate JWTs with cached JWKS for normal requests. Use /introspect for sensitive writes, account settings, admin/moderation actions, suspicious sessions, or short cached hard checks. It requires a confidential client, so call it from your backend — never from a browser or a public client.
  • State your freshness policy with max_age: For step-up-sensitive entry points, send max_age on the authorize request rather than inspecting auth_time after the fact — SparkVault then refuses to mint a silent SSO credential from an older authentication instead of handing you one to reject.
  • Rotate refresh tokens atomically: Store only the latest OIDC refresh_token. Each successful refresh invalidates the previous token, so clients must persist the replacement before making another refresh attempt.
  • Sign out through the front door: For browser sign-out, navigate top-level to /end_session with your id_token_hint and a registered post_logout_redirect_uri — it ends the managed session and returns the browser to you. For server-side/BFF teardown without a navigation, call /revoke with the current refresh token instead. Either way, clear your local session cookies.

Account Configuration

Configure your Identity tenant through the SparkVault dashboard or API:

Setting Description
redirect_uris Allowed callback URLs for OIDC and simple mode. May be empty when only SDK/simple verification is used; OIDC clients still require at least one redirect URI.
Company Domains Verified account domains authorize Identity SDK browser origins. Add them under Company / Domains; redirect URIs do not grant SDK origin trust.
clients Registered client IDs and secrets
clients[].id_token_signed_response_alg Per-client signing algorithm, set at client creation. Omit it for the default EdDSA; set "RS256" to have both the client's id_token and its access token signed with the tenant's RSA-2048 key instead. Choose RS256 only when your JWT library has no EdDSA support. Changing it later means deleting and re-creating the client.
methods.sparklink Enable magic links. Keys: enabled, ttl_minutes (default 15, range 5-60), require_ip_binding (default true), auto_resend_on_expiry (default false)
methods.otp_email Enable email OTP codes (default: true)
methods.otp_sms Enable SMS OTP codes (default: false)
methods.otp_voice Enable voice call OTP codes (default: false)
methods.passkey Enable WebAuthn passkeys (default: true)
methods.social.* Enable social providers: google, apple, microsoft, github, facebook, linkedin
methods.enterprise.* Enable enterprise SAML providers: okta, entra, onelogin, ping, jumpcloud. A metadata_url must be a direct public HTTPS URL with no credentials or redirects; fetches enforce public DNS, a 10-second timeout, and a 1 MiB limit.
methods Canonical source for both enabled auth methods and derived email/phone input availability. Passkeys are SVID-scoped and are not tied to an email or phone setting.
rate_limits Rate limiting config: attempts_before_backoff, initial_backoff_seconds, etc.
tokens.id_token_ttl_seconds OIDC ID token lifetime (default 3600). SDK-mode simple tokens use a fixed 300-second TTL.
Custom Domains

You can CNAME your own domain to auth.sparkvault.com for white-label authentication. Contact support to configure custom domain mapping.

Pricing

Identity is included in your SparkVault subscription. There is no per-verification charge, and failed attempts and retries are never billed. Each subscription tier includes a monthly login-attempt allowance; accounts approaching the allowance are warned, and a soft gate applies at 120% of the tier ceiling.

View Full Pricing →

Try It

SparkVault uses its own Identity Product for authentication. You experienced it when you logged in! To integrate Identity into your own application:

  1. Configure your redirect URIs in account settings
  2. Create a client ID and secret
  3. Implement the OIDC flow as shown above
  4. Test with the hosted login page

Configure Identity →