Skip to content

Smart Handoff SSO

Overview

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

How It Works

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 /auth/external/handoff/verify
    AS->>RP: Return access_token + session + user
    RP->>RP: Save to localStorage
    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

Setup

1. Initialize SDK with OAuth Enabled

import { createAuthrim } from '@authrim/web';
const auth = await createAuthrim({
issuer: 'https://auth.example.com',
clientId: 'my-rp-client-id',
enableOAuth: true, // Required for Smart Handoff SSO
});

2. Handle Handoff Callback

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) {
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',
enableOAuth: true,
});
// ✨ One-liner: Verify token, save session, cleanup 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 === 'HANDOFF_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>

What verifyAndSave() Does

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 to localStorage (compatible with SessionAuthImpl)
  4. Cleans up sessionStorage (only handoff-specific keys)
  5. Emits auth:login event for reactive state updates

Advanced Usage

Low-Level Token Verification

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

const tokenData = await auth.handoff.verify(
handoffToken,
state,
clientId
);
// Token data contains:
// - access_token
// - expires_in
// - session: { id, userId, createdAt, expiresAt }
// - user: { id, email, name, emailVerified }
// Manually save to localStorage
const storageKey = auth.session.getStorageKey();
localStorage.setItem(storageKey, tokenData.access_token);

Get Storage Key

For debugging or advanced use cases:

const storageKey = auth.session.getStorageKey();
console.log('Storage key:', storageKey);
// Check if token exists
const token = localStorage.getItem(storageKey);
console.log('Has token:', !!token);

Error Handling

The SDK provides unified error handling:

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

Security Considerations

State Parameter

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.

Storage Key Compatibility

The handoff implementation uses the same storage key calculation as SessionAuthImpl:

// Both use the same key:
const storageKey = `authrim_session_${hash(issuer + ':' + clientId)}`;

This ensures handoff-authenticated sessions work seamlessly with auth.session.get().

PKCE Not Required

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

Complete Example

<!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) {
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',
enableOAuth: true,
});
// 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 === 'HANDOFF_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',
enableOAuth: true,
});
// 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>

Diagnostic Logging

Enable diagnostic logging to debug handoff flows:

const auth = await createAuthrim({
issuer: 'https://auth.example.com',
clientId: 'my-rp-client-id',
enableOAuth: true,
diagnosticLogging: {
enabled: true,
persistToStorage: true,
},
});
// Logs will include:
// - "Handoff token verification started"
// - "Handoff token verified successfully"
// - "Saving handoff session to storage"
// - "Handoff storage cleaned up"

Comparison: Smart Handoff vs Silent Login

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 dependencyNoneThird-party cookies
Browser supportAll browsersLimited (Safari ITP)