Skip to main content

Step-Up Token

The step-up token is a short-lived signed JWT returned after successful MFA verification. It provides cryptographic proof that a user completed step-up verification for a specific action, allowing your backend or downstream services to trust the result without calling VoxKey again.

When to use it

ArchitectureUse step-up token?Why
Server-side app (backend calls VoxKey directly)NoYour backend already received verified: true -- it trusts VoxKey's response directly
SPA (frontend calls VoxKey, backend performs action)YesYour backend cannot trust the frontend claiming "MFA passed" -- the token provides cryptographic proof
Microservices (Service A verifies, Service B acts)YesService B validates the token independently without calling VoxKey

Requesting a step-up token

Set issue_step_up_token: true when creating the challenge:

const challenge = await fetch(
'https://app.voxkey.io/api/v1/{realmUUID}/mfa/challenges',
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
factor_type: 'totp',
purpose: 'account.delete',
issue_step_up_token: true,
}),
}
).then(r => r.json());

After successful verification, the response includes step_up_token:

{
"verified": true,
"factor_type": "totp",
"verified_at": "2026-04-03T10:04:30Z",
"step_up_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6InN0ZXBfdXArand0In0..."
}

Passing the token to your backend

Send it in the X-Step-Up-Token header when calling your backend:

await fetch('/api/delete-account', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'X-Step-Up-Token': stepUpToken,
},
});

Claims reference

ClaimTypeDescription
typstringAlways step_up+jwt -- distinguishes this from access/ID tokens
issstringRealm issuer URL (same as in access tokens)
substringUser ID
realm_idstringRealm UUID
client_idstringOAuth client ID that initiated the challenge
token_jtistringJTI of the access token used during challenge creation (for audit)
challenge_idstringMFA challenge ID
purposestringThe purpose that was verified (e.g. account.delete)
acrstringAuthentication context class: urn:voxkey:aal2
amrarrayAuthentication methods used, e.g. ["pwd", "otp"]
auth_timenumberUnix timestamp of the MFA verification
iatnumberIssued at (Unix timestamp)
expnumberExpires at: iat + 300 (5 minutes)
jtistringUnique token identifier

TTL: 5 minutes. This is intentionally short -- the token should be used immediately after MFA verification.

How to validate

Validation follows the standard JWT verification flow, with additional checks specific to step-up tokens.

Step-by-step

  1. Fetch JWKS from https://app.voxkey.io/oauth2/{realmUUID}/oidc/jwks
  2. Match kid from the JWT header to a key in the JWKS
  3. Verify signature using RS256
  4. Check typ -- must be step_up+jwt (prevents accepting regular access tokens as step-up proof)
  5. Check iss -- must match your realm's issuer URL
  6. Check exp -- must be in the future
  7. Check sub -- must match the authenticated user making the request
  8. Check purpose -- must match the action being performed
  9. Check client_id -- must match your application's client ID
# Get the JWKS
curl https://app.voxkey.io/oauth2/{realmUUID}/oidc/jwks

Node.js example (jose)

import * as jose from 'jose';

const JWKS = jose.createRemoteJWKSet(
new URL('https://app.voxkey.io/oauth2/{realmUUID}/oidc/jwks')
);

async function validateStepUpToken(token, expectedPurpose, expectedUserId) {
const { payload, protectedHeader } = await jose.jwtVerify(token, JWKS, {
issuer: 'https://app.voxkey.io/oauth2/{realmUUID}',
algorithms: ['RS256'],
});

// Ensure this is actually a step-up token, not an access token
if (protectedHeader.typ !== 'step_up+jwt') {
throw new Error('Not a step-up token');
}

// Verify the purpose matches the action being performed
if (payload.purpose !== expectedPurpose) {
throw new Error(
`Purpose mismatch: expected ${expectedPurpose}, got ${payload.purpose}`
);
}

// Verify the token belongs to the requesting user
if (payload.sub !== expectedUserId) {
throw new Error('User mismatch');
}

return payload;
}

// Usage in an Express route
app.post('/api/delete-account', authMiddleware, async (req, res) => {
const stepUpToken = req.headers['x-step-up-token'];
if (!stepUpToken) {
return res.status(403).json({ error: 'Step-up verification required' });
}

try {
await validateStepUpToken(stepUpToken, 'account.delete', req.user.sub);
} catch (e) {
return res.status(403).json({ error: e.message });
}

// Token is valid -- proceed with account deletion
await deleteAccount(req.user.sub);
res.json({ deleted: true });
});

PHP example (firebase/php-jwt)

use Firebase\JWT\JWT;
use Firebase\JWT\JWK;

function validateStepUpToken(
string $stepUpToken,
string $expectedPurpose,
string $expectedUserId
): object {
// Fetch and cache JWKS (cache in production!)
$jwksJson = file_get_contents(
'https://app.voxkey.io/oauth2/{realmUUID}/oidc/jwks'
);
$jwks = json_decode($jwksJson, true);
$keys = JWK::parseKeySet($jwks);

// Decode and verify signature
$payload = JWT::decode($stepUpToken, $keys);

// Check token type
if (($payload->typ ?? null) !== 'step_up+jwt') {
throw new \Exception('Not a step-up token');
}

// Check purpose matches the action
if ($payload->purpose !== $expectedPurpose) {
throw new \Exception("Purpose mismatch: expected {$expectedPurpose}");
}

// Check user identity
if ($payload->sub !== $expectedUserId) {
throw new \Exception('User mismatch');
}

return $payload;
}

// Usage in a Laravel controller
public function deleteAccount(Request $request)
{
$stepUpToken = $request->header('X-Step-Up-Token');
if (!$stepUpToken) {
return response()->json(['error' => 'Step-up verification required'], 403);
}

try {
validateStepUpToken($stepUpToken, 'account.delete', $request->user()->id);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 403);
}

// Token is valid -- proceed with account deletion
$request->user()->delete();
return response()->json(['deleted' => true]);
}

Soft binding to access token

The step-up token contains token_jti -- the JTI of the access token that was active when the challenge was created. However, your backend should not enforce strict matching between the current access token and token_jti. The user may legitimately refresh their access token between creating the challenge and using the step-up token.

Required checks: sub + client_id + purpose + exp

token_jti is for audit trails and forensics only -- it lets you correlate which session initiated the MFA challenge.