Identity v1

Overview

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

OIDC 1.0

Standards Compliant

Ed25519

EdDSA 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
Managed Identity

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

Quick Start

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

1. Add the SDK

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

2. Add a Login Button

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

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

3. Verify Token (Backend)

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;
That's It!

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

Integration Options

Programmatic

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

Attach to a Button

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

Hosted Page

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

Session Management

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

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

Refresh and Introspection

OIDC clients refresh through /token with grant_type=refresh_token. Refresh rotates the session token and mints a new short-lived access token. Logout calls /revoke, which deletes the managed Identity session. Servers can call /introspect to verify that an access token is still active, was issued after the session's current cutoff, and is not blocked by the user's connected-site policy.

SDK/simple mode keeps the integration lightweight:

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

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

Lockdown and Site Blocks

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

End-User Portal (auth.sv)

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

Recommended Pattern

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

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

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

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

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

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

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

  res.json({ success: true, user });
});

Integration Options

Choose the integration method that best fits your application architecture:

JavaScript SDK (Recommended)

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

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

View SDK Guide →

Full OIDC Flow

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

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

Simple Redirect

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

  • Simple implementation
  • No client secret
  • Ed25519 signed callbacks

Quick Start (OIDC)

Implement authentication in 4 steps:

1. Generate PKCE Challenge

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

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

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

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

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

2. Redirect to Authorization

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

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

3. Handle Callback & Exchange Code

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

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

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

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

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

4. Verify ID Token

javascript
import { 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, ... }
}
Token Exchange Server-Side

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

OIDC Endpoints

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

Discovery Document

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

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

json Response
{
  "issuer": "https://auth.sparkvault.com/acc_example",
  "authorization_endpoint": "https://auth.sparkvault.com/acc_example/authorize",
  "token_endpoint": "https://auth.sparkvault.com/acc_example/token",
  "introspection_endpoint": "https://auth.sparkvault.com/acc_example/introspect",
  "revocation_endpoint": "https://auth.sparkvault.com/acc_example/revoke",
  "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)

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

Returns the JSON Web Key Set containing the tenant's Ed25519 public key for verifying ID token signatures.

json Response
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "base64url-encoded-public-key",
      "kid": "key-id-for-rotation",
      "use": "sig",
      "alg": "EdDSA"
    }
  ]
}
JWKS Caching

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

GET /{account_id}/authorize

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

Query Parameters (Required)

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

Query Parameters (Optional)

ParameterTypeRequiredDescription
nonce string Optional Binds ID token to request. Returned in token claims.

Token Endpoint

POST /{account_id}/token

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

Authorization Code Request Body

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

Refresh Token Request Body

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

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

Success Response

FieldTypeDescription
id_token string 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)
Refresh Token Rotation

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

Introspection Endpoint

POST /{account_id}/introspect

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

Request Body

ParameterTypeRequiredDescription
token string Required Access token returned by the token endpoint
client_id string Required Your registered client ID

Active Response

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

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

When to introspect

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

Revocation Endpoint

POST /{account_id}/revoke

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

Request Body

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

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

Response

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

ID Token Claims

Two Token Formats

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

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

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

The OIDC ID token is a JWT signed with Ed25519 (EdDSA algorithm). After verification, extract these claims:

Standard Claims

FieldTypeDescription
iss string Issuer URL: https://auth.sparkvault.com/{account_id}
sub string Subject identifier; equals the SVID
aud string Audience: your client_id
exp integer Expiration time (Unix timestamp)
iat integer Issued at time (Unix timestamp)
auth_time integer Time of user authentication (Unix timestamp)
nonce string Nonce from authorization request (if provided)

Identity Claims

FieldTypeDescription
svid string Canonical SparkVault ID for the person
identity string Verified email or phone identity value
identity_type string "email" or "phone"
email string The verified email address, only for email identities
email_verified boolean True for email identities
phone_number string The verified E.164 phone number, only for phone identities
phone_number_verified boolean True for phone identities
amr string[] Every factor presented: ["passkey"], ["sparklink"], ["otp"], ["social:google"], ["sso"], ["saml:okta"], or a primary plus second factor like ["otp", "totp"] / ["otp", "recovery"]
acr string Authentication context class: urn:sparkvault:identity:1 (single factor) or urn:sparkvault:identity:mfa when a distinct second factor (authenticator app or recovery code) cleared the login
json Example Decoded ID Token Payload
{
  "iss": "https://auth.sparkvault.com/acc_example",
  "sub": "ing_019e66a4e27875f2822ede0e4f5d8792",
  "aud": "your-client-id",
  "exp": 1703980800,
  "iat": 1703977200,
  "auth_time": 1703977195,
  "nonce": "abc123",
  "svid": "ing_019e66a4e27875f2822ede0e4f5d8792",
  "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

POST /{account_id}/verify/session

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

Request Body

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

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

Start Verification

GET /{account_id}/verify

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

Query Parameters

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

Success Callback

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

Callback Parameters

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

Verify Callback Signature

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

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

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

Server-to-Server API (Direct OTP)

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

When to Use Direct API

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

Send Verification Code

POST /{account_id}/otp/send

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

Request Body

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

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

Kindling Token

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

Verify Code

POST /{account_id}/otp/verify

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

Request Body

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

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

JWT Token

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

Server-to-Server Example (Node.js)

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

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

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

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

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

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

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

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

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

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

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

Server-to-Server Example (Python)

python
import requests

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

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

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

Incorrect Code

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

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

Error Responses

Error Responses

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

Passkey API (WebAuthn)

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

SDK Handles This

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

Check Passkey Exists

POST /{account_id}/passkey/check

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

Request Body

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

Start Registration

POST /{account_id}/passkey/register

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

Request Body

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

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

Complete Registration

POST /{account_id}/passkey/register/complete

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

Request Body

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

Start Authentication

POST /{account_id}/passkey/verify

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

Request Body

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

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

Complete Authentication

POST /{account_id}/passkey/verify/complete

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

Request Body

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

Passkey Management

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

GET /{account_id}/passkey/list

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

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

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

Path Parameters

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

Passkey Popup

GET /{account_id}/passkey/popup

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

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

Two-Factor Authentication (TOTP)

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

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

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

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

Hosted Code-Entry Page

GET /{account_id}/second-factor

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

Query Parameters

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

Verify Second Factor

POST /{account_id}/second-factor/verify

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

Request Body

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

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

Social Login API

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

Initiate Social Login

GET /{account_id}/social/:provider

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

Path Parameters

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

Query Parameters

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

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

Social Login Callback

GET /social/:provider/callback

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

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

Supported Providers

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

Enterprise SSO (SAML)

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

Enterprise Feature

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

Initiate SAML Login

GET /{account_id}/saml/:provider

Redirects user to the configured SAML Identity Provider for authentication.

Path Parameters

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

Query Parameters

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

SAML Assertion Consumer Service (ACS)

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

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

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

Supported SAML Providers

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

IdP Configuration

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

Hosted Login Page

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

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

Serves the hosted login page with all enabled authentication methods.

Query Parameters

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

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

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

Custom Domains

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

SDK Configuration

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

GET /{account_id}/config

Returns SDK configuration including enabled authentication methods and branding.

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

methods can also include the enterprise SSO IDs enterprise_okta, enterprise_entra, enterprise_onelogin, enterprise_ping, and enterprise_jumpcloud. sso 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

javascript
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

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

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

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

# JWKS cache
_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

StatusCodeDescription
400 invalid_request Missing or malformed parameter. Returned directly, not redirected.
400 unsupported_response_type response_type other than "code". Returned directly.
400 invalid_scope Scope missing "openid" or otherwise unsupported. Returned directly.
400 invalid_client client_id or redirect_uri not registered for this client. Returned directly.
302 access_denied User denied consent or cancelled. Delivered on redirect_uri.
302 login_required prompt=none could not be satisfied silently. Delivered on redirect_uri.

Token Endpoint Errors

Error Responses

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

Security Best Practices

  • Always use PKCE: The S256 code challenge is mandatory. Never skip PKCE, even for confidential clients.
  • Validate state parameter: Compare the returned state to your stored value to prevent CSRF attacks.
  • Verify all ID token claims: Check issuer, audience, expiration, and nonce. Don't trust tokens without full validation.
  • Use nonce for replay protection: Generate a unique nonce per request and verify it in the ID token.
  • Keep client_secret server-side: Never expose your client secret in frontend code or version control.
  • Use HTTPS redirect URIs: All registered redirect URIs must use HTTPS with no insecure origin exceptions.
  • Cache JWKS appropriately: Cache for 5-10 minutes, but re-fetch if you encounter an unknown key ID.
  • Use SVID as the app identity key: Store your product profile, roles, preferences, uploads, and moderation state under svid. Do not use email, phone, or a hash of either as the canonical account link.
  • Use a BFF for browser sessions: Exchange codes server-side and store refresh/session material only in encrypted or signed HttpOnly, Secure, SameSite=Lax cookies. Browser JavaScript should never see refresh tokens.
  • Validate locally, introspect selectively: Validate JWTs with cached JWKS for normal requests. Use /introspect for sensitive writes, account settings, admin/moderation actions, suspicious sessions, or short cached hard checks.
  • 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 /revoke with 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.
Custom Domains

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

Pricing

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

View Full Pricing →

Try It

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

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

Configure Identity →