Skip to content

Smart Handoff SSO

Smart Handoff SSO enables secure cross-domain Single Sign-On by transferring authentication state from an Authorization Server (AS) to a Relying Party (RP) through a secure handoff token.

This approach is ideal when:

  • You want seamless SSO without user interaction
  • You need to bridge authentication between different applications
  • You want to avoid iframe-based flows entirely
sequenceDiagram
    participant User
    participant AS as Authorization Server
    participant RP as Relying Party App

    User->>AS: Already authenticated
    User->>AS: Click "Open App" button
    AS->>AS: Generate handoff_token
    AS->>RP: Redirect with handoff_token + state
    RP->>AS: POST /handoff/verify
    AS->>RP: Return access_token + session + user
    RP->>RP: Save access token using configured token storage
    RP->>User: Redirect to app (authenticated)

Security Features:

  • Short-lived tokens: Handoff tokens expire quickly (typically 60 seconds)
  • One-time use: Tokens can only be used once
  • State CSRF protection: State parameter validated against handoff-specific namespace
  • Referrer protection: Token immediately removed from URL
import { createAuthrim } from '@authrim/web';
const auth = await createAuthrim({
issuer: 'https://auth.example.com',
clientId: 'my-rp-client-id',
storage: {
storage: 'memory', // default; use 'sessionStorage' only if reload persistence is required
},
});

In your callback page (callback.html):

<!DOCTYPE html>
<html>
<head>
<title>Processing Authentication</title>
</head>
<body>
<div id="loading">Processing authentication...</div>
<div id="error" style="display:none"></div>
<script src="https://unpkg.com/@authrim/web@latest/dist/authrim-web.umd.global.js"></script>
<script>
async function handleHandoffCallback() {
const params = new URLSearchParams(window.location.search);
const handoffToken = params.get('handoff_token');
const state = params.get('state');
if (!handoffToken || !state) {
return false; // Not a handoff callback
}
console.log('[Authrim] Handoff callback detected');
// Remove token from URL immediately (Referrer protection)
history.replaceState(null, '', window.location.pathname);
try {
const auth = await AuthrimWeb.createAuthrim({
issuer: 'https://auth.example.com',
clientId: 'my-rp-client-id',
});
// Verify token, save access token, cleanup handoff storage
await auth.handoff.verifyAndSave(handoffToken, state);
console.log('[Authrim] Handoff login successful');
// Redirect to app
window.location.href = '/';
return true;
} catch (error) {
console.error('[Authrim] Handoff error:', error);
if (error.code === 'state_mismatch') {
showError('CSRF attack detected. Please try again.');
} else {
showError('Authentication failed. Please try again.');
}
return true;
}
}
// Initialize
(async () => {
const handled = await handleHandoffCallback();
if (!handled) {
// Not a handoff callback, handle other flows
// ...
}
})();
</script>
</body>
</html>

The auth.handoff.verifyAndSave() method automatically:

  1. Verifies handoff token with the Authorization Server
  2. Validates state parameter against handoff-specific namespace (CSRF protection)
  3. Saves access token using the configured session storage policy (memory by default, sessionStorage when explicitly configured)
  4. Cleans up sessionStorage (only handoff-specific keys)
  5. Emits auth:login event for reactive state updates

If you need more control, use auth.handoff.verify():

const tokenData = await auth.handoff.verify(
handoffToken,
state,
clientId,
{ include: 'session,user' }
);
// Token data contains:
// - access_token
// - expires_in
// - session: { id, userId, createdAt, expiresAt }
// - user: { id, email, name, emailVerified }
// Prefer verifyAndSave() when you want the SDK to store the access token.

For debugging or advanced use cases:

const storageKey = auth.session.getStorageKey();
console.log('Storage key:', storageKey);
// Only sessionStorage configurations persist a token under this key.
// Memory-only clients keep token material in memory.

The SDK provides unified error handling:

try {
await auth.handoff.verifyAndSave(handoffToken, state);
} catch (error) {
if (error.code === 'invalid_token') {
// Token invalid or expired
console.error('Token verification failed:', error.message);
} else if (error.code === 'state_mismatch') {
// CSRF attack detected
console.error('State mismatch - CSRF protection:', error.message);
} else {
// Other errors
console.error('Handoff error:', error);
}
}

The SDK uses a handoff-specific namespace for state storage:

  • Handoff state: authrim:handoff:state
  • Social login state: authrim:direct:social:state
  • OAuth state: authrim:oauth:state

This prevents namespace collisions and ensures each flow has isolated CSRF protection.

The handoff implementation delegates access-token storage to SessionAuthImpl:

// Available for advanced diagnostics.
const storageKey = auth.session.getStorageKey();

This ensures handoff-authenticated sessions work seamlessly with auth.session.get(). In the default memory profile, tokens are not written to Web Storage.

Unlike OAuth flows, Smart Handoff does not require PKCE:

  • The Authorization Server handles the OAuth flow internally
  • The handoff token exchange is a separate, secure channel
  • State parameter provides CSRF protection
<!DOCTYPE html>
<html>
<head>
<title>My App - Callback</title>
</head>
<body>
<div id="app">
<div id="loading">Processing authentication...</div>
<div id="success" style="display:none">Success! Redirecting...</div>
<div id="error" style="display:none"></div>
</div>
<script src="https://unpkg.com/@authrim/web@latest/dist/authrim-web.umd.global.js"></script>
<script>
function showState(state) {
document.getElementById('loading').style.display = state === 'loading' ? 'block' : 'none';
document.getElementById('success').style.display = state === 'success' ? 'block' : 'none';
document.getElementById('error').style.display = state === 'error' ? 'block' : 'none';
}
function showError(message) {
document.getElementById('error').textContent = message;
showState('error');
}
async function handleHandoffCallback() {
const params = new URLSearchParams(window.location.search);
const handoffToken = params.get('handoff_token');
const state = params.get('state');
if (!handoffToken || !state) {
return false;
}
console.log('[Authrim] Handoff callback detected');
history.replaceState(null, '', window.location.pathname);
try {
const auth = await AuthrimWeb.createAuthrim({
issuer: 'https://auth.example.com',
clientId: 'my-rp-client-id',
});
// Verify and save in one call
await auth.handoff.verifyAndSave(handoffToken, state);
console.log('[Authrim] Handoff login successful');
showState('success');
setTimeout(() => {
window.location.href = '/';
}, 1000);
return true;
} catch (error) {
console.error('[Authrim] Handoff error:', error);
if (error.code === 'state_mismatch') {
showError('CSRF attack detected. Please try again.');
} else {
showError('Authentication failed. Please try again.');
}
return true;
}
}
// Main initialization
(async () => {
const handled = await handleHandoffCallback();
if (!handled) {
// Handle other callback types (social login, OAuth, etc.)
const auth = await AuthrimWeb.createAuthrim({
issuer: 'https://auth.example.com',
clientId: 'my-rp-client-id',
});
// Check for social login callback
if (auth.social.hasCallbackParams()) {
const result = await auth.social.handleCallback();
if (result.error) {
showError(result.error.message);
} else {
showState('success');
setTimeout(() => window.location.href = '/', 1000);
}
}
}
})();
</script>
</body>
</html>

Enable diagnostic logging to debug handoff flows:

const auth = await createAuthrim({
issuer: 'https://auth.example.com',
clientId: 'my-rp-client-id',
diagnosticLogging: {
enabled: true,
persistToStorage: true,
},
});
// Logs will include:
// - "Handoff token verification started"
// - "Handoff token verified successfully"
// Diagnostic decisions include smart-handoff verification success/failure.
FeatureSmart Handoff SSOSilent Login SSO
FlowAS → RP handoff tokenRP → AS prompt=none
User interactionNone (seamless)None (seamless)
Token lifetimeShort (60s)Standard OAuth
PKCE requiredNoYes
Use caseAS-initiated SSORP-initiated SSO
Cookie dependencyNoneTop-level navigation; no hidden third-party iframe
Browser supportAll browsersLimited (Safari ITP)