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:
- Verifies handoff token with the Authorization Server
- Validates state parameter against handoff-specific namespace (CSRF protection)
- Saves access token to localStorage (compatible with
SessionAuthImpl) - Cleans up sessionStorage (only handoff-specific keys)
- Emits
auth:loginevent 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 localStorageconst 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 existsconst 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
| Feature | Smart Handoff SSO | Silent Login SSO |
|---|---|---|
| Flow | AS → RP handoff token | RP → AS prompt=none |
| User interaction | None (seamless) | None (seamless) |
| Token lifetime | Short (60s) | Standard OAuth |
| PKCE required | No | Yes |
| Use case | AS-initiated SSO | RP-initiated SSO |
| Cookie dependency | None | Third-party cookies |
| Browser support | All browsers | Limited (Safari ITP) |
Related
- Cross-Domain SSO — Silent Login SSO using prompt=none
- Session Management — Managing user sessions
- Error Handling — Unified error handling