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.

In SvelteKit, handoff flows are handled entirely server-side using hooks, providing:

  • Zero client-side JavaScript for authentication
  • Automatic session management via cookies
  • 303 See Other redirects (POST → GET)
  • Type-safe server-side APIs
sequenceDiagram
    participant User
    participant AS as Authorization Server
    participant Hook as SvelteKit Hook
    participant App as SvelteKit App

    User->>AS: Already authenticated
    User->>AS: Click "Open App" button
    AS->>AS: Generate handoff_token
    AS->>Hook: Redirect with handoff_token + state
    Hook->>AS: POST /auth/external/handoff/verify
    AS->>Hook: Return access_token + session + user
    Hook->>Hook: Set session cookie
    Hook->>Hook: Clean URL (remove token)
    Hook->>App: Redirect (303 See Other)
    App->>User: Show authenticated app

Key Features:

  • Server-side only: No token exposure to client
  • Cookie-based sessions: HttpOnly, Secure, SameSite
  • Automatic cleanup: Token removed from URL
  • 303 redirects: Proper POST → GET redirect handling
Terminal window
npm install @authrim/sveltekit

In src/hooks.server.ts:

import { sequence } from '@sveltejs/kit/hooks';
import {
createAuthHandle,
createHandoffHandler
} from '@authrim/sveltekit/server';
import { env } from '$env/dynamic/private';
export const handle = sequence(
// First: Initialize auth context
createAuthHandle({
sessionSecret: env.AUTHRIM_SESSION_SECRET,
callbackPaths: ['/callback'],
}),
// Second: Handle handoff callbacks
createHandoffHandler({
issuer: env.AUTHRIM_ISSUER,
clientId: env.AUTHRIM_CLIENT_ID,
errorRedirect: '/login?error=handoff_failed',
sessionOptions: {
sessionSecret: env.AUTHRIM_SESSION_SECRET,
},
})
);

The handoff handler automatically:

  1. Detects handoff_token in URL parameters
  2. Verifies token with Authorization Server
  3. Saves session to cookie
  4. Removes token from URL
  5. Redirects to clean URL with 303 See Other

The handler provides automatic processing:

// Pseudo-code of what happens internally
export function createHandoffHandler(options) {
return async ({ event, resolve }) => {
// Check for handoff token
if (event.url.searchParams.has('handoff_token')) {
const handoffToken = event.url.searchParams.get('handoff_token');
const state = event.url.searchParams.get('state');
// Verify with Authorization Server
const result = await verifyHandoffToken(event, options);
if (!result.success) {
// Error: redirect to login
throw redirect(303, options.errorRedirect || '/login');
}
// Success: remove token from URL and redirect
const cleanUrl = new URL(event.url);
cleanUrl.searchParams.delete('handoff_token');
cleanUrl.searchParams.delete('state');
throw redirect(303, cleanUrl.toString());
}
return resolve(event);
};
}
interface HandoffVerifyOptions {
/**
* Authrim IdP URL.
*/
issuer: string;
/**
* OAuth client ID.
*/
clientId: string;
/**
* Handoff verify endpoint
* @default '/auth/external/handoff/verify'
*/
verifyEndpoint?: string;
/**
* Error redirect path
* @default '/login'
*/
errorRedirect?: string;
/**
* Session manager options
*/
sessionOptions?: {
cookieName?: string; // default: 'authrim_session'
sameSite?: 'strict' | 'lax' | 'none'; // default: 'lax'
secure?: boolean; // default: true in production
path?: string; // default: '/'
maxAge?: number; // default: 7 days
httpOnly?: boolean; // default: true
sessionSecret?: string | Uint8Array | CryptoKey; // required
};
}

If you need more control, use verifyHandoffToken() in a load function:

src/routes/callback/+page.server.ts
import { verifyHandoffToken } from '@authrim/sveltekit/server';
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const result = await verifyHandoffToken(event, {
issuer: env.AUTHRIM_ISSUER,
clientId: env.AUTHRIM_CLIENT_ID,
sessionOptions: {
sessionSecret: env.AUTHRIM_SESSION_SECRET,
},
});
if (!result.success) {
throw redirect(302, '/login?error=' + result.error);
}
// Session is now saved in cookie
// Redirect to app
throw redirect(302, '/dashboard');
};
createHandoffHandler({
issuer: env.AUTHRIM_ISSUER,
clientId: env.AUTHRIM_CLIENT_ID,
errorRedirect: '/login?error=handoff_failed',
sessionOptions: {
sessionSecret: env.AUTHRIM_SESSION_SECRET,
},
})
createHandoffHandler({
issuer: env.AUTHRIM_ISSUER,
clientId: env.AUTHRIM_CLIENT_ID,
sessionOptions: {
cookieName: 'my_app_session',
sameSite: 'strict',
secure: true,
path: '/',
maxAge: 14 * 24 * 60 * 60, // 14 days
httpOnly: true,
sessionSecret: env.AUTHRIM_SESSION_SECRET,
},
})

After successful handoff, access session data in your components:

<script lang="ts">
import { getAuthContext } from '@authrim/sveltekit';
const auth = getAuthContext();
const { isAuthenticated, user } = auth.stores;
// Note: Handoff is handled automatically by createHandoffHandler()
// in hooks.server.ts. This component only accesses the session.
</script>
{#if $isAuthenticated}
<div>
<p>Welcome, {$user?.name ?? $user?.email}!</p>
<button onclick={() => auth.signOut()}>Sign Out</button>
</div>
{:else}
<a href="/login">Sign In</a>
{/if}

The handler uses 303 See Other instead of 302 Found:

// After verification, redirect with 303
throw redirect(303, cleanUrl.toString());

Why 303?

  • Converts POST to GET (proper semantic)
  • Prevents form resubmission on refresh
  • Recommended by HTTP spec for post-authentication redirects

Session cookies are configured with security best practices:

{
httpOnly: true, // Prevents XSS
secure: true, // HTTPS only (in production)
sameSite: 'lax', // CSRF protection
path: '/', // Application-wide
maxAge: 604800, // 7 days
}

State validation happens server-side:

  • State is sent by Authorization Server
  • Verified during handoff token exchange
  • No client-side state management needed
src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import { createAuthHandle, createHandoffHandler } from '@authrim/sveltekit/server';
import { env } from '$env/dynamic/private';
const issuer = env.AUTHRIM_ISSUER;
const clientId = env.AUTHRIM_CLIENT_ID;
const sessionSecret = env.AUTHRIM_SESSION_SECRET;
if (!issuer || !clientId || !sessionSecret) {
console.warn('[Authrim] Missing environment variables');
}
export const handle = sequence(
createAuthHandle({
sessionSecret: sessionSecret || '',
callbackPaths: ['/callback'],
}),
createHandoffHandler({
issuer: issuer || '',
clientId: clientId || '',
errorRedirect: '/login?error=handoff_failed',
sessionOptions: {
sessionSecret: sessionSecret || '',
},
})
);
src/app.d.ts
import type { ServerAuthContext } from '@authrim/sveltekit/server';
declare global {
namespace App {
interface Locals {
auth?: ServerAuthContext;
}
}
}
export {};
src/routes/dashboard/+page.server.ts
import { requireAuth } from '@authrim/sveltekit/server';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
// Automatically redirects to /login if not authenticated
const { auth } = await requireAuth()(event);
return {
user: auth.user,
session: auth.session,
};
};
src/routes/dashboard/+page.svelte
<script lang="ts">
import { getAuthContext } from '@authrim/sveltekit';
import type { PageData } from './$types';
export let data: PageData;
const auth = getAuthContext();
async function handleSignOut() {
await auth.signOut();
// Automatically redirects to login
}
</script>
<div class="dashboard">
<h1>Dashboard</h1>
<p>Welcome, {data.user.name}!</p>
<p>Email: {data.user.email}</p>
<button onclick={handleSignOut}>Sign Out</button>
</div>
// In +page.server.ts
export const load: PageServerLoad = async (event) => {
const result = await verifyHandoffToken(event);
if (!result.success) {
// Log error for monitoring
console.error('[Handoff] Verification failed:', result.error);
// Redirect to login with error message
throw redirect(303, `/login?error=${encodeURIComponent(result.error)}`);
}
return { session: result.session, user: result.user };
};
src/routes/login/+page.svelte
<script lang="ts">
import { page } from '$app/stores';
$: error = $page.url.searchParams.get('error');
const errorMessages: Record<string, string> = {
'handoff_failed': 'Authentication failed. Please try again.',
'token_expired': 'Session expired. Please sign in again.',
'invalid_state': 'Security check failed. Please try again.',
};
</script>
{#if error}
<div class="error">
{errorMessages[error] || 'An error occurred during authentication.'}
</div>
{/if}
<a href="/auth/login">Sign In</a>
Terminal window
# Set environment variables
export AUTHRIM_ISSUER="https://auth.example.com"
export AUTHRIM_CLIENT_ID="dev-client-id"
export AUTHRIM_SESSION_SECRET="replace-with-at-least-32-bytes"
# Start dev server
npm run dev
# Test handoff flow
# 1. Get handoff token from Authorization Server
# 2. Visit: http://localhost:5173/callback?handoff_token=xxx&state=yyy
# 3. Should automatically redirect to / with session
Terminal window
# Verify handoff token manually
curl -X POST https://auth.example.com/auth/external/handoff/verify \
-H "Content-Type: application/json" \
-d '{
"handoff_token": "your-handoff-token",
"state": "your-state",
"client_id": "your-client-id"
}'
Feature@authrim/sveltekit@authrim/web
ProcessingServer-sideClient-side
Session storageEncrypted HttpOnly cookieBrowser storage/token profile
Type safetyFull TypeScriptFull TypeScript
SSR support✅ Yes❌ No
Setup complexityLow (hooks only)Medium (callback page)
SecurityHigher (HttpOnly cookies)Good (localStorage)
Browser compatibilityAll browsersAll browsers

Problem: Handler doesn’t trigger

Solution: Check URL parameters

// Add debug logging in hooks.server.ts
export const handle = sequence(
createAuthHandle({ ... }),
createHandoffHandler({ ... }),
async ({ event, resolve }) => {
console.log('URL params:', Object.fromEntries(event.url.searchParams));
return resolve(event);
}
);

Problem: Session cookie not saved

Solution: Check cookie configuration

createHandoffHandler({
// ...
sessionOptions: {
secure: false, // Set to false for local development (http)
sameSite: 'lax',
sessionSecret: env.AUTHRIM_SESSION_SECRET,
},
})

Problem: Keeps redirecting

Solution: Check createAuthHandle callback paths

createAuthHandle({
// ...
callbackPaths: ['/callback'], // Should match your callback route
})