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 Signatures
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)
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://auth.sparkvault.com/acc_xxx/.well-known/jwks.json'
});
const getKey = (header, cb) => client.getSigningKey(header.kid, (err, key) => cb(err, key?.getPublicKey()));
const decoded = jwt.verify(token, getKey, {
algorithms: ['EdDSA'],
issuer: 'https://auth.sparkvault.com/acc_xxx',
audience: 'acc_xxx'
});
const svid = decoded.svid || decoded.sub;
import jwt
import requests
jwks_url = "https://auth.sparkvault.com/acc_xxx/.well-known/jwks.json"
jwks = requests.get(jwks_url).json()
public_key = jwt.algorithms.OKPAlgorithm.from_jwk(jwks["keys"][0])
decoded = jwt.decode(
token,
public_key,
algorithms=["EdDSA"],
issuer="https://auth.sparkvault.com/acc_xxx",
audience="acc_xxx"
)
svid = decoded.get("svid") or decoded["sub"]
import "github.com/golang-jwt/jwt/v5"
jwksURL := "https://auth.sparkvault.com/acc_xxx/.well-known/jwks.json"
keySet, _ := jwk.Fetch(ctx, jwksURL)
token, _ := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
key, _ := keySet.LookupKeyID(t.Header["kid"].(string))
return key.Key, nil
})
svid := token.Claims.(jwt.MapClaims)["svid"].(string)
require 'jwt'
require 'net/http'
jwks_url = "https://auth.sparkvault.com/acc_xxx/.well-known/jwks.json"
jwks = JSON.parse(Net::HTTP.get(URI(jwks_url)))
key = JWT::JWK.import(jwks["keys"].first).public_key
decoded = JWT.decode(token, key, true, {
algorithm: 'EdDSA',
iss: 'https://auth.sparkvault.com/acc_xxx',
aud: 'acc_xxx',
verify_iss: true,
verify_aud: true
}).first
svid = decoded["svid"] || decoded["sub"]
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
$jwksUrl = "https://auth.sparkvault.com/acc_xxx/.well-known/jwks.json";
$jwks = json_decode(file_get_contents($jwksUrl), true);
$keys = JWK::parseKeySet($jwks);
$decoded = JWT::decode($token, $keys);
$svid = $decoded->svid ?? $decoded->sub;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
var handler = new JwtSecurityTokenHandler();
var jwksUrl = "https://auth.sparkvault.com/acc_xxx/.well-known/jwks.json";
var keys = new JsonWebKeySet(await httpClient.GetStringAsync(jwksUrl));
var principal = handler.ValidateToken(token, new TokenValidationParameters {
IssuerSigningKeys = keys.Keys,
ValidateIssuer = true,
ValidIssuer = "https://auth.sparkvault.com/acc_xxx",
ValidateAudience = true,
ValidAudience = "acc_xxx"
}, out _);
var svid = principal.FindFirst("svid")?.Value ?? principal.FindFirst("sub")?.Value;
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.
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.
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 });
});
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()) |
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) |
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 { ed25519 } from '@noble/curves/ed25519';
async function verifyIdToken(idToken, accountId, clientId, nonce) {
const [headerB64, payloadB64, signatureB64] = idToken.split('.');
// Fetch public key from JWKS
const jwksResponse = await fetch(
`https://auth.sparkvault.com/${accountId}/.well-known/jwks.json`
);
const jwks = await jwksResponse.json();
const publicKey = base64urlDecode(jwks.keys[0].x);
// Verify Ed25519 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');
// Decode and validate claims
const payload = JSON.parse(atob(payloadB64.replace(/-/g,'+').replace(/_/g,'/')));
if (payload.iss !== `https://auth.sparkvault.com/${accountId}`) {
throw new Error('Invalid issuer');
}
if (payload.aud !== clientId) {
throw new Error('Invalid audience');
}
if (payload.nonce !== nonce) {
throw new Error('Invalid nonce');
}
if (payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Token expired');
}
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",
"jwks_uri": "https://auth.sparkvault.com/acc_example/.well-known/jwks.json",
"response_types_supported": ["code"],
"response_modes_supported": ["query", "json"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["EdDSA"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"],
"revocation_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"],
"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"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["openid", "email", "phone"]
}
JWKS (Public Keys)
/{account_id}/.well-known/jwks.json
Returns the JSON Web Key Set containing the tenant's Ed25519 public key for verifying ID token signatures.
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "base64url-encoded-public-key",
"kid": "key-id-for-rotation",
"use": "sig",
"alg": "EdDSA"
}
]
}
Cache the JWKS response for 5-10 minutes to avoid excessive requests. The key ID (kid) in the JWT header indicates which key to use. Re-fetch if you encounter an unknown kid.
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. |
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 | EdDSA-signed JWT containing user claims (authorization_code grant) |
access_token |
string | Short-lived bearer token containing svid and session_id |
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.
Introspection Endpoint
/{account_id}/introspect
Checks whether an access token is still active for its managed Identity session.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
token |
string | Required | Access token returned by the token endpoint |
client_id |
string | Required | Your registered client ID |
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. |
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 algorithm). 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 | Time of user authentication (Unix timestamp) |
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 |
{
"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",
"identity": "user@example.com",
"identity_type": "email",
"email": "user@example.com",
"email_verified": true,
"amr": ["passkey"],
"acr": "urn:sparkvault:identity:1"
}
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';
async function verifyCallback(params, accountId) {
// Fetch public key from JWKS
const jwksResponse = await fetch(
`https://auth.sparkvault.com/${accountId}/.well-known/jwks.json`
);
const jwks = await jwksResponse.json();
const publicKeyBytes = base64urlDecode(jwks.keys[0].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 tells the SDK whether to attempt a silent
cross-domain session check (enabled) and whether the hosted page is the only sign-in
surface (forced). 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';
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
let jwksCache = null;
let jwksCacheExpiry = 0;
async function getPublicKey() {
if (jwksCache && Date.now() < jwksCacheExpiry) {
return jwksCache;
}
const res = await fetch(`${IDENTITY_URL}/.well-known/jwks.json`);
const jwks = await res.json();
jwksCache = base64urlDecode(jwks.keys[0].x);
jwksCacheExpiry = Date.now() + 5 * 60 * 1000; // 5 min
return jwksCache;
}
// 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 publicKey = await getPublicKey();
// 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
_jwks_cache = {'key': None, 'expires': 0}
def get_public_key():
import time
if _jwks_cache['key'] and time.time() < _jwks_cache['expires']:
return _jwks_cache['key']
response = requests.get(f'{IDENTITY_URL}/.well-known/jwks.json')
jwks = response.json()
key_b64 = jwks['keys'][0]['x']
# Add padding if needed
padding = 4 - (len(key_b64) % 4)
if padding != 4:
key_b64 += '=' * padding
key_bytes = base64.urlsafe_b64decode(key_b64)
_jwks_cache['key'] = key_bytes
_jwks_cache['expires'] = time.time() + 300 # 5 min
return key_bytes
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 payload
padding = 4 - (len(payload_b64) % 4)
if padding != 4:
payload_b64 += '=' * padding
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
# Verify signature
public_key = get_public_key()
verify_key = VerifyKey(public_key)
signed_data = f'{parts[0]}.{parts[1]}'.encode()
padding = 4 - (len(signature_b64) % 4)
if padding != 4:
signature_b64 += '=' * padding
signature = base64.urlsafe_b64decode(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. |
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. -
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. -
Revoke on logout: Call
/revokewith the current refresh token, then 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 |
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 |
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