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
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
<script src="https://cdn.sparkvault.com/sdk/v1/sparkvault.js"></script>
2. Add a Login Button
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;
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.
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.
- User authenticates via SparkVault Identity (passkey, OTP, social login, etc.)
- SparkVault resolves or creates the person's SVID and records the connected site session
- SDK/simple mode returns a signed JWT; OIDC returns
id_token,access_token, andrefresh_token - Your backend verifies the token (signature, issuer, audience, expiration)
- 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
suborsvid, 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.
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.
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 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.
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
// 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 });
});
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 withPOST /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.enabledsetting (on by default) controls participation; opting out makes every sign-in fully interactive even with a live SSO session.
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.
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.
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.
SparkLinks (Magic Links)
SparkLinks are encrypted, single-use verification links. When a user clicks a SparkLink, they are instantly verified with no code entry required. SparkLinks are ideal for email-based verification where one-click convenience is prioritized.
- SDK opens a WebSocket to
wss://ws.sparkvault.comand receives a connectionId - SDK requests a SparkLink with the connectionId: SparkVault seals the signed verify URL inside an encrypted, single-use spark and sends the email
- User receives email with the SparkLink URL:
https://x.sv/{link_code} - User clicks the link →
x.svserves an interstitial page without burning the link; the page's JavaScript POSTs back to burn the spark, decrypt the verify URL, and redirect. Crawlers and email scanners that don't execute JavaScript never trigger the POST, so they can never consume the link - The verify callback completes verification, generates the JWT, and pushes it to the SDK via WebSocket using the connectionId (with a single-use kindling result as the fallback when the socket was lost)
- SDK receives the token instantly. Authentication complete
Key Characteristics
- Single-use: Each SparkLink can only be used once. After verification, the spark is destroyed.
- Time-limited: SparkLinks expire after a configurable TTL (default 15 minutes).
- Encrypted and signed: The spark payload is the encrypted verify URL, and the verify callback parameters are HMAC-SHA256 signed against tampering.
- Scanner-proof by deferred burn: A plain GET of the link never consumes it; only the interstitial page's JavaScript POST burns the spark, so non-JS link-preview bots and email scanners cannot use it up.
- Branded ceremony: Users see your organization branding during the verification process.
- WebSocket push: Verification result is delivered instantly to the SDK via WebSocket: no polling, no iframes, no CSP requirements.
- IP binding: Optional IP address binding prevents link usage from a different network (default: on).
- Auto-resend: Optionally auto-sends a new link on IP mismatch or expiry (default: off).
URL Structure
https://x.sv/{link_code}
Example:
https://x.sv/aB3xK9mQpR7sT2uVwY4zC1
The URL carries a single opaque, single-use link code. No account ID, identity, or destination appears in the link. Opening it serves a deferred-burn interstitial: the page's JavaScript POSTs to consume the grant, the server burns the spark and decrypts the sealed verify URL, and the browser is redirected. Because a bare GET never burns the link, email scanners cannot consume it.
SDK Usage
// The popup offers every method you have enabled, including
// SparkLink, and the person picks one. Pre-fill the email to
// skip the first step.
const result = await sparkvault.products.identity.signIn({
email: 'user@example.com'
});
SparkLink API Endpoints
Send SparkLink
/{account_id}/sparklink/send
Send a SparkLink magic link to an email address.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
email |
string | Required | Email address to send the SparkLink to |
connection_id |
string | Required | WebSocket connection ID for real-time token delivery (obtained by the SDK when it opens its socket). Requests without it are rejected. |
auth_request_id |
string | Optional | OIDC auth request ID when completing a hosted-login authorization-code flow |
simple_mode |
object | Optional | Simple redirect completion context |
curl -X POST 'https://auth.sparkvault.com/{account_id}/sparklink/send' \
-H 'Content-Type: application/json' \
-d '{
"email": "user@example.com",
"connection_id": "conn_abc123..."
}'
{
"data": {
"link_code": "aB3xK9mQpR7sT2uVwY4zC1",
"expiresAt": 1704067800000,
"kindling": "kdl_abc123..."
}
}
expiresAt is in milliseconds. kindling is a durable fallback
handle: if the SDK's WebSocket is lost (for example, a suspended mobile tab), it reclaims the verified
result with GET /{account_id}/sparklink/result below.
Verify SparkLink
/{account_id}/sparklink/verify
Internal callback reached after the x.sv interstitial burns the spark. Completes verification and pushes the result to the SDK. Not intended to be called directly.
Query Parameters (generated and signed by SparkVault)
| Parameter | Type | Required | Description |
|---|---|---|---|
session_id |
string | Required | Opaque verify-session pointer. The verified identity and any redirect context live in the session row, never in the URL. |
connection_id |
string | Required | WebSocket connection ID the result is pushed to |
expires |
integer | Required | Expiration timestamp (Unix epoch seconds) |
sig |
string | Required | HMAC-SHA256 signature binding all parameters against post-issue tampering |
auth_request_id |
string | Optional | OIDC hosted-login request being completed |
kindling |
string | Optional | WebSocket-loss fallback handle |
This endpoint is the decrypted destination of the sealed spark payload. The x.sv interstitial redirects here after the burn. It validates the HMAC signature, completes verification, pushes the signed JWT to the SDK over the WebSocket (or writes the kindling result spark as fallback), and renders a success page. The person who clicked the link never receives the JWT in their browser.
Claim Result (WebSocket-Loss Fallback)
/{account_id}/sparklink/result
Claim the verified result by kindling when the WebSocket push could not reach the SDK. Single-use: reading the result burns it.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
kindling |
string | Required | The kindling handle returned by the send call |
{ "data": { "pending": true } }
{
"data": {
"type": "sparklink_verified",
"token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...",
"identity": "user@example.com",
"identityType": "email",
"svid": "ing_019e66a4e27875f2822ede0e4f5d8792",
"session_id": "sess_abc123",
"refresh_token": "rt_..."
}
}
Returns pending: true until the click finishes processing. The SDK calls this automatically
when it refocuses after a lost socket; the result is burn-on-read, so it can never become a second
standing auth path.
Alternative: Via OTP Endpoint
You can also send SparkLinks via the OTP API with method: "sparklink"
(connection_id is required for this method too):
curl -X POST 'https://auth.sparkvault.com/{account_id}/otp/send' \
-H 'Content-Type: application/json' \
-d '{
"recipient": "user@example.com",
"method": "sparklink",
"connection_id": "conn_abc123..."
}'
{
"success": true,
"method": "sparklink",
"message": "Magic link sent"
}
How Verification Is Delivered
The SDK receives the verification result instantly via WebSocket push: no polling, no iframes required. The SDK opens a WebSocket connection and receives a connectionId, which is embedded in the SparkLink payload. When the user clicks the link, the server pushes the token directly to the SDK through the WebSocket connection. If the socket was suspended with the tab, the SDK reclaims the result once through the kindling handle.
Configuration
SparkLink Settings (methods.sparklink)
| Parameter | Type | Required | Description |
|---|---|---|---|
enabled |
boolean | Optional | Enable/disable SparkLinksDefault: true |
ttl_minutes |
integer | Optional | Time before SparkLink expires (range: 5-60)Default: 15 |
require_ip_binding |
boolean | Optional | Bind link to requester IP addressDefault: true |
auto_resend_on_expiry |
boolean | Optional | Auto-send a new link on IP mismatch or expiryDefault: false |
These four keys are the only valid methods.sparklink settings. Anything else is rejected as
an unknown setting. White-label link domains are configured through the separate account-level
custom_domains list, not per method.
SparkLink payloads are encrypted at rest. The destination verify URL is sealed inside a single-use spark and derived live at burn time, never stored or embedded in plaintext. Verify-callback parameters are HMAC-SHA256 signed. Scanner protection comes from the deferred-burn interstitial: a GET never consumes the link, so crawlers that don't execute JavaScript cannot burn it. WebSocket push uses TLS encryption and connectionId-based routing. Each spark is burned atomically on first use, making replay attacks impossible. Optional IP binding adds network-level protection.
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
JavaScript SDK (Recommended)
For comprehensive SDK documentation including installation, TypeScript types, error handling, and React integration examples, see the JavaScript SDK Guide.
The simplest way to add identity verification. You trigger it, SparkVault handles everything in the first-party sign-in popup, then returns a cryptographically signed pass/fail result. Your code just waits for the result. No need to understand the verification internals.
1. You call signIn() → 2. SparkVault popup opens → 3. User verifies (email/passkey/etc) → 4. You get signed result
1. Include the SDK
<!-- Add to your HTML -->
<script src="https://cdn.sparkvault.com/sdk/v1/sparkvault.js"></script>
Or install via npm: npm install @sparkvault/sdk-js
2. Initialize & Verify
// Initialize with your account ID
const sparkvault = SparkVault.init({
accountId: 'acc_YOUR_ACCOUNT_ID' // From your dashboard
});
// Trigger verification - SparkVault handles everything
async function login() {
const result = await sparkvault.products.identity.signIn();
// User verified! result.token is a signed JWT proving verification
console.log('Verified:', result.identity); // "user@example.com" or "+14155551234"
console.log('Type:', result.identityType); // "email", "phone", or "social"
// Send the signed token to your backend for verification and SVID mapping
await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: result.token })
});
}
3. Verify Token (Server-Side)
// CRITICAL: Always verify tokens on your backend using JWKS
import * as jose from 'jose';
const ACCOUNT_ID = 'acc_your_account_id';
app.post('/api/auth/verify', async (req, res) => {
const { token } = req.body;
try {
// Fetch JWKS and verify Ed25519 signature
const jwksUrl = `https://auth.sparkvault.com/${ACCOUNT_ID}/.well-known/jwks.json`;
const JWKS = jose.createRemoteJWKSet(new URL(jwksUrl));
const { payload } = await jose.jwtVerify(token, JWKS, {
issuer: `https://auth.sparkvault.com/${ACCOUNT_ID}`,
audience: ACCOUNT_ID,
algorithms: ['EdDSA']
});
// Token is valid. SVID is the stable identity key for your app.
const svid = payload.svid || payload.sub;
const user = await findOrCreateUserBySvid(svid, {
identity: payload.identity, // email or phone
identityType: payload.identity_type, // 'email' or 'phone'
method: payload.method // 'otp', 'passkey', 'sparklink', 'social:google', ...
});
res.json({ success: true, user });
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});
SDK Options
signIn() Options
| Parameter | Type | Required | Description |
|---|---|---|---|
email |
string | Optional | Pre-fill email address (mutually exclusive with phone) |
phone |
string | Optional | Pre-fill phone number in E.164 format, e.g. +14155551234 (mutually exclusive with email) |
backdropBlur |
boolean | Optional | Apply blur effect to the background overlay behind the popupDefault: true |
flow |
string | Optional | "auto" (default) and "popup" open the first-party popup; "redirect" navigates the current tab to the sign-in page instead: the opt-out for hosts that prefer a full-page redirect over a popup on mobile (its return leg is handleRedirectResult()) |
prompt |
string | Optional | select_account — always show the sign-in page's account chooser instead of silently continuing as the current account (the affordance for a "switch account" button). Any other value is ignored. |
onSuccess |
function | Optional | Callback with the verified result before the promise resolves |
onError |
function | Optional | Callback with the error before the promise rejects |
onCancel |
function | Optional | Callback when the person closes the popup without signing in |
Resilience: handleRedirectResult()
Call handleRedirectResult() once on every page load. It redeems a returned one-time,
PKCE-bound code (from a flow: 'redirect' sign-in, or the silent SSO self-heal), never the
token itself, and, when a popup's result was lost (a mobile browser discarded the backgrounded tab
mid-login), it silently re-mints from the SSO session so the sign-in still completes. It no-ops (resolves
null) otherwise. Wiring it is strongly recommended: it is what makes the popup reliable on
mobile. The result arrives here as the same SignInResult the popup delivers.
// Runs on every page load; redeems a returned code or self-heals a lost result.
sv.products.identity.handleRedirectResult({
onSuccess: (result) => createSession(result.token), // same handler as signIn()
}).catch((error) => showError(error));
handleRedirectResult() Options
| Parameter | Type | Required | Description |
|---|---|---|---|
onSuccess |
function | Optional | Callback with the verified result (same shape as signIn()) before the promise resolves |
onError |
function | Optional | Callback with the error before the promise rejects |
onCancel |
function | Optional | Callback when the person cancelled sign-in on the hosted page (the promise resolves null) |
Response Object
SignInResult
| Field | Type | Description |
|---|---|---|
token |
string | Signed JWT - send to your backend for validation |
identity |
string | The verified identity (email or phone number) |
identityType |
string | "email", "phone", or "social" (social-only logins return "social") |
svid |
string? | Canonical SparkVault ID for the person |
sessionId |
string? | Managed SparkVault Identity session ID |
refreshToken |
string? | Managed-session refresh token, when the flow returns one |
redirect |
string? | Redirect URL, present only for OIDC/simple mode flows |
recommendations |
array? | Ordered post-login nudges (default-mode logins only): { type: "passkey" } or { type: "backup_identifier", missingType: "email" | "phone" } |
SDK Token Claims
When you decode the JWT token from the SDK, it contains these claims:
Token Payload
| Field | Type | Description |
|---|---|---|
iss |
string | Issuer: https://auth.sparkvault.com/{account_id} |
sub |
string | Subject; equals the SVID for managed Identity tokens |
aud |
string | Audience: your account_id |
jti |
string | Unique token ID (UUID) |
svid |
string | Canonical SparkVault ID for the person |
session_id |
string | Managed Identity session ID |
identity |
string | The verified email or phone number |
identity_type |
string | "email" or "phone" |
method |
string | Auth method used (AMR value): "otp", "passkey", "sparklink", "social:google", "social:apple", "social:microsoft", "social:github", "social:facebook", "social:linkedin", "sso", "totp", "recovery", "saml:okta", etc. |
amr |
string[] | All factors presented, e.g. ["otp"] or ["otp", "totp"] when a second factor cleared the login |
acr |
string | urn:sparkvault:identity:1 (single factor) or urn:sparkvault:identity:mfa (distinct second factor presented) |
action_hash |
string? | Present only on action-bound passkey approvals: hex SHA-256 of the approved action (see Passkey API) |
picture |
string? | Public avatar URL — the stable https://my.auth.sv/{svid}/avatar — present only while the person publishes a visible photo. Embed it directly in an <img>. Its presence is the supported signal that a real photo exists, since the URL itself always renders. See auth.sv Public Avatars. |
verified_at |
integer | Verification timestamp (Unix epoch seconds) |
iat |
integer | Issued at timestamp (Unix epoch seconds) |
exp |
integer | Expiration timestamp (Unix epoch seconds) |
{
"iss": "https://auth.sparkvault.com/acc_example",
"sub": "ing_019e66a4e27875f2822ede0e4f5d8792",
"aud": "acc_example",
"jti": "6f1b2a34-8c9d-4e5f-a012-3456789abcde",
"svid": "ing_019e66a4e27875f2822ede0e4f5d8792",
"session_id": "sess_abc123",
"identity": "user@example.com",
"identity_type": "email",
"method": "otp",
"amr": ["otp"],
"acr": "urn:sparkvault:identity:1",
"verified_at": 1703977200,
"iat": 1703977200,
"exp": 1703977500
}
SDK-mode tokens default to a 300-second (5 minute) TTL. They are a proof of verification
to hand to your backend promptly, not a session credential. Mint your own app session (or use the OIDC/BFF
flow) after verifying. The 3600-second id_token_ttl_seconds setting applies to OIDC ID tokens only.
Error Handling
try {
await sparkvault.products.identity.signIn();
} catch (error) {
switch (error.code) {
case 'user_cancelled': // User closed the popup
break;
case 'network_error': // Connection failed
break;
case 'invalid_config': // Bad accountId or malformed configuration
break;
case 'ORIGIN_NOT_ALLOWED': // This page's domain is not a verified
break; // company domain for the account (HTTP 400).
// Add the domain in the dashboard; retrying
// never clears an origin block.
}
}
Rate limiting does not surface as a dedicated error code: too many attempts fail as a validation error whose message includes the wait time (for example, "Too many attempts. Please try again in 5 minutes.").
passkey: WebAuthn biometric authentication
otp_email: 6-digit code via email
otp_sms: 6-digit code via SMS
otp_voice: 6-digit code via voice call
sparklink: One-click magic link via email
social_google: Sign in with Google
social_apple: Sign in with Apple
social_microsoft: Sign in with Microsoft
social_github: Sign in with GitHub
social_facebook: Sign in with Facebook
social_linkedin: Sign in with LinkedIn
enterprise_okta / enterprise_entra / enterprise_onelogin / enterprise_ping / enterprise_jumpcloud: Enterprise SSO via SAML
React Example
import { useCallback, useState } from 'react';
import SparkVault from '@sparkvault/sdk-js';
// Initialize once, outside the component
const sparkvault = SparkVault.init({
accountId: 'acc_YOUR_ACCOUNT_ID'
});
function LoginButton() {
const [isVerifying, setIsVerifying] = useState(false);
const handleLogin = useCallback(async () => {
setIsVerifying(true);
try {
// Open the sign-in popup
const result = await sparkvault.products.identity.signIn();
// Send token to backend
await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: result.token })
});
} catch (err) {
if (err.code !== 'user_cancelled') throw err;
} finally {
setIsVerifying(false);
}
}, []);
return (
<button onClick={handleLogin} disabled={isVerifying}>
{isVerifying ? 'Verifying...' : 'Log In'}
</button>
);
}
Quick Start (OIDC)
Implement authentication in 4 steps:
1. Generate PKCE Challenge
// 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
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
// 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
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, ... }
}
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
/{account_id}/.well-known/openid-configuration
Returns the OIDC discovery document with all endpoint URLs and supported features.
{
"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)
/{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.
{
"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"
}
]
}
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
/{account_id}/authorize
Initiates the authorization flow. Validates parameters and redirects to the hosted login page.
Query Parameters (Required)
| Parameter | Type | Required | Description |
|---|---|---|---|
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)
| Parameter | Type | Required | Description |
|---|---|---|---|
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. |
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
/{account_id}/token
Exchanges an authorization code for tokens or rotates a refresh token. Requires client authentication.
Authorization Code Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
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
| Parameter | Type | Required | Description |
|---|---|---|---|
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 |
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
| Field | Type | Description |
|---|---|---|
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) |
Each successful refresh returns a new refresh_token. Persist it atomically before making
the next refresh request; replaying an older token fails.
UserInfo Endpoint
/{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
| Field | Type | Description |
|---|---|---|
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.
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.
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
/{account_id}/introspect
Checks whether an access token is still active for its managed Identity session. Confidential clients only.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
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. |
/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
| Field | Type | Description |
|---|---|---|
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.
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
/{account_id}/revoke
Revokes the managed Identity session identified by a refresh token or access token.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
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 |
Include the client secret with HTTP Basic auth or client_secret in the request body for confidential clients.
Response
| Field | Type | Description |
|---|---|---|
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)
/{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
| Parameter | Type | Required | Description |
|---|---|---|---|
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 |
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
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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. |
{
"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.
<!-- 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:
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
/{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
| Parameter | Type | Required | Description |
|---|---|---|---|
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 |
{
"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
/{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
| Parameter | Type | Required | Description |
|---|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
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
| Field | Type | Description |
|---|---|---|
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.
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
/{account_id}/otp/send
Send a 6-digit verification code to an email address or phone number.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
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 |
curl -X POST 'https://auth.sparkvault.com/{account_id}/otp/send' \
-H 'Content-Type: application/json' \
-d '{
"recipient": "user@example.com",
"method": "email"
}'
{
"success": true,
"kindling": "kdl_abc123...",
"expires_at": 1704067800,
"method": "email"
}
method echoes the request's method value ("email", "sms", or
"voice").
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
/{account_id}/otp/verify
Verify the code entered by the user and receive a signed JWT token.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
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 |
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..."
}'
{
"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" }).
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)
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)
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.
{
"verified": false,
"kindling": "kdl_abc123...",
"expires_at": 1704067800,
"retry_after": 1704067830
}
Error Responses
Error Responses
| Status | Code | Description |
|---|---|---|
| 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.
The JavaScript SDK handles the full passkey flow automatically. These endpoints are documented for custom implementations or server-to-server integrations.
Check Passkey Exists
/{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
| Parameter | Type | Required | Description |
|---|---|---|---|
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. |
curl -X POST 'https://auth.sparkvault.com/{account_id}/passkey/check' \
-H 'Content-Type: application/json' \
-d '{ "email": "user@example.com" }'
{
"identity_valid": true,
"has_passkey": true
}
Start Registration
/{account_id}/passkey/register
Start passkey registration. Returns WebAuthn options for navigator.credentials.create() plus a one-time session.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
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) |
{
"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
/{account_id}/passkey/register/complete
Complete passkey registration with the credential from navigator.credentials.create().
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
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 |
{
"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
/{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
| Parameter | Type | Required | Description |
|---|---|---|---|
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 |
{
"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"
}
}
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
/{account_id}/passkey/verify/complete
Complete passkey authentication with the assertion from navigator.credentials.get().
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
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 |
{
"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).
/{account_id}/passkey/list
List the authenticated person's passkeys. Requires Authorization: Bearer <jwt>.
{
"passkeys": [
{
"credential_id": "base64url-credential-id",
"device_name": "MacBook Pro",
"created_at": 1703977200,
"last_used": 1704067800,
"backup_eligible": true,
"backup_state": true
}
],
"count": 1
}
/{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
| Parameter | Type | Required | Description |
|---|---|---|---|
credential_id |
string | Required | Base64url-encoded credential ID to delete |
{
"success": true,
"credential_id": "base64url-credential-id"
}
Passkey Popup
/{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:
{
"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
/{account_id}/second-factor
Render the hosted second-factor code-entry page for top-level browser logins.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
ticket |
string | Required | Pending-login ticket minted at the second-factor gate |
Verify Second Factor
/{account_id}/second-factor/verify
Submit the authenticator code (or a recovery code) and finish the held login.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
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.
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.
SAML integration requires configuration of your IdP. Contact support to set up SAML for your organization.
Initiate SAML Login
/{account_id}/saml/:provider
Redirects user to the configured SAML Identity Provider for authentication.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
provider |
string | Required | Provider ID: okta, entra, onelogin, ping, jumpcloud |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
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)
/{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 |
|---|---|---|
| Okta | okta | Full SAML 2.0 support |
| Microsoft Entra ID (Azure AD) | entra | Full SAML 2.0 support |
| OneLogin | onelogin | Full SAML 2.0 support |
| Ping Identity | ping | Full SAML 2.0 support |
| JumpCloud | jumpcloud | Full SAML 2.0 support |
IdP Configuration
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:
/authorizevalidates your request and redirects here withauth_request=<id>. - Simple verify:
/verify?session=<id>redirects here withmode=simple&session=<id>.
/{account_id}
Serves the hosted login page with all enabled authentication methods.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
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 |
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.
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).
/{account_id}/config
Returns SDK configuration including enabled authentication methods and branding.
{
"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
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
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
| Status | Code | Description |
|---|---|---|
| 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
| Status | Code | Description |
|---|---|---|
| 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 |
{
"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=Laxcookies. Browser JavaScript should never see refresh tokens. -
Validate locally, introspect selectively: Validate JWTs with cached JWKS
for normal requests. Use
/introspectfor 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, sendmax_ageon the authorize request rather than inspectingauth_timeafter 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_sessionwith yourid_token_hintand a registeredpost_logout_redirect_uri— it ends the managed session and returns the browser to you. For server-side/BFF teardown without a navigation, call/revokewith 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. |
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.
Try It
SparkVault uses its own Identity Product for authentication. You experienced it when you logged in! To integrate Identity into your own application:
- Configure your redirect URIs in account settings
- Create a client ID and secret
- Implement the OIDC flow as shown above
- Test with the hosted login page
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
/{account_id}/social/:providerRedirects user to the social provider's OAuth consent page.
Path Parameters
providerQuery Parameters
redirect_uristateauth_request_idsimple_modeclient_idnoncecode_challengecode_challenge_methodopener_originSocial Login Callback
/social/:provider/callbackHandles 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
googleapplemicrosoftgithubfacebooklinkedin