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
| Architecture | Use step-up token? | Why |
|---|---|---|
| Server-side app (backend calls VoxKey directly) | No | Your backend already received verified: true -- it trusts VoxKey's response directly |
| SPA (frontend calls VoxKey, backend performs action) | Yes | Your backend cannot trust the frontend claiming "MFA passed" -- the token provides cryptographic proof |
| Microservices (Service A verifies, Service B acts) | Yes | Service 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
| Claim | Type | Description |
|---|---|---|
typ | string | Always step_up+jwt -- distinguishes this from access/ID tokens |
iss | string | Realm issuer URL (same as in access tokens) |
sub | string | User ID |
realm_id | string | Realm UUID |
client_id | string | OAuth client ID that initiated the challenge |
token_jti | string | JTI of the access token used during challenge creation (for audit) |
challenge_id | string | MFA challenge ID |
purpose | string | The purpose that was verified (e.g. account.delete) |
acr | string | Authentication context class: urn:voxkey:aal2 |
amr | array | Authentication methods used, e.g. ["pwd", "otp"] |
auth_time | number | Unix timestamp of the MFA verification |
iat | number | Issued at (Unix timestamp) |
exp | number | Expires at: iat + 300 (5 minutes) |
jti | string | Unique 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
- Fetch JWKS from
https://app.voxkey.io/oauth2/{realmUUID}/oidc/jwks - Match
kidfrom the JWT header to a key in the JWKS - Verify signature using RS256
- Check
typ-- must bestep_up+jwt(prevents accepting regular access tokens as step-up proof) - Check
iss-- must match your realm's issuer URL - Check
exp-- must be in the future - Check
sub-- must match the authenticated user making the request - Check
purpose-- must match the action being performed - 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.