SvelteKit Authentication with Keycloak: Complete Guide
Last updated: March 2026
SvelteKit has emerged as a strong contender in the full-stack JavaScript framework space, combining Svelte’s reactive simplicity with server-side rendering, API routes, and file-based routing. But like most frameworks, it leaves authentication as an exercise for the developer.
Keycloak solves this by providing a complete identity layer: single sign-on, multi-factor authentication, social login, and role-based access control. You can integrate Keycloak with SvelteKit using Auth.js (formerly NextAuth.js), which has an official SvelteKit adapter.
This guide builds a SvelteKit application with Keycloak authentication using Auth.js. It covers server hooks for session management, load functions for server-side auth checks, protected routes, form actions with authentication context, and role-based UI rendering.
Prerequisites
- Node.js 18+ and npm 9+
- A running Keycloak instance (version 22+). Use the Skycloak Docker Compose Generator or a managed instance from Skycloak.
- Familiarity with Svelte and SvelteKit basics
Start a local Keycloak:
docker run -p 8080:8080
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin
quay.io/keycloak/keycloak:26.0 start-dev
Keycloak Client Configuration
Create a Realm and Client
- Log into
http://localhost:8080/admin - Create a realm called
sveltekit-app - Go to Clients > Create client:
- Client type: OpenID Connect
- Client ID:
sveltekit-client
- Capability Config:
- Client authentication: On (confidential client, since Auth.js runs server-side)
- Standard flow: Enabled
- Direct access grants: Disabled
- Login Settings:
- Valid redirect URIs:
http://localhost:5173/* - Valid post logout redirect URIs:
http://localhost:5173/* - Web origins:
http://localhost:5173
- Valid redirect URIs:
- After saving, go to the Credentials tab and copy the client secret
Create Roles and a Test User
- Go to Realm roles > create
userandadminroles - Go to Users > create a test user with a password
- Assign the
userrole under Role mappings
Project Setup
Create a new SvelteKit project:
npx sv create sveltekit-keycloak
cd sveltekit-keycloak
Select SvelteKit minimal, TypeScript, and add any other options you prefer.
Install Auth.js and the SvelteKit adapter:
npm install @auth/sveltekit @auth/core
Your package.json dependencies should include:
{
"dependencies": {
"@auth/core": "^0.37.0",
"@auth/sveltekit": "^1.7.0"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.10.0",
"svelte": "^5.0.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}
Environment Configuration
Create a .env file in the project root:
AUTH_SECRET=generate-a-random-32-char-string-here
AUTH_TRUST_HOST=true
KEYCLOAK_ID=sveltekit-client
KEYCLOAK_SECRET=your-client-secret-from-keycloak
KEYCLOAK_ISSUER=http://localhost:8080/realms/sveltekit-app
Generate a proper AUTH_SECRET:
npx auth secret
Auth.js Configuration
Create src/auth.ts:
import { SvelteKitAuth } from '@auth/sveltekit';
import Keycloak from '@auth/core/providers/keycloak';
export const { handle, signIn, signOut } = SvelteKitAuth({
providers: [
Keycloak({
clientId: process.env.KEYCLOAK_ID!,
clientSecret: process.env.KEYCLOAK_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER!,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
// Persist Keycloak data in the JWT token
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.idToken = account.id_token;
token.expiresAt = account.expires_at;
}
// Extract roles from Keycloak token
if (profile) {
const realmAccess = (profile as Record<string, unknown>)
.realm_access as { roles?: string[] } | undefined;
token.roles = realmAccess?.roles || [];
}
return token;
},
async session({ session, token }) {
// Expose roles and tokens to the client session
return {
...session,
accessToken: token.accessToken as string,
roles: (token.roles as string[]) || [],
user: {
...session.user,
id: token.sub as string,
},
};
},
},
pages: {
signIn: '/auth/signin',
},
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days
},
});
Type Declarations
Extend Auth.js types to include your custom session properties. Create src/app.d.ts:
import type { Session } from '@auth/core/types';
declare module '@auth/core/types' {
interface Session {
accessToken?: string;
roles?: string[];
user: {
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
}
}
declare module '@auth/core/jwt' {
interface JWT {
accessToken?: string;
refreshToken?: string;
idToken?: string;
expiresAt?: number;
roles?: string[];
}
}
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {
interface Locals {
session: Session | null;
}
}
}
export {};
Server Hooks
SvelteKit hooks run on every request, making them the right place to handle authentication middleware.
Create src/hooks.server.ts:
import type { Handle } from '@sveltejs/kit';
import { handle as authHandle } from './auth';
import { sequence } from '@sveltejs/kit/hooks';
import { redirect } from '@sveltejs/kit';
// Auth.js handle
const authorizationHandle: Handle = async ({ event, resolve }) => {
const session = await event.locals.auth();
// Define protected route patterns
const protectedPaths = ['/dashboard', '/profile', '/admin', '/settings'];
const adminPaths = ['/admin'];
const isProtected = protectedPaths.some((path) =>
event.url.pathname.startsWith(path)
);
const isAdminRoute = adminPaths.some((path) =>
event.url.pathname.startsWith(path)
);
if (isProtected && !session) {
throw redirect(303, `/auth/signin?callbackUrl=${event.url.pathname}`);
}
if (isAdminRoute && session) {
const roles = session.roles || [];
if (!roles.includes('admin')) {
throw redirect(303, '/unauthorized');
}
}
return resolve(event);
};
export const handle = sequence(authHandle, authorizationHandle);
The sequence function chains multiple handle functions together. Auth.js’s handle runs first (populating event.locals.auth()), then the authorization handle checks whether the user can access the requested route.
Layout and Session
Create src/routes/+layout.server.ts to load the session on every page:
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
const session = await event.locals.auth();
return {
session,
};
};
Create src/routes/+layout.svelte:
<script lang="ts">
import type { LayoutData } from './$types';
import Nav from '$lib/components/Nav.svelte';
let { data, children } = $props<{ data: LayoutData; children: any }>();
</script>
<Nav session={data.session} />
<main>
{@render children()}
</main>
Navigation Component
Create src/lib/components/Nav.svelte:
<script lang="ts">
import { signIn, signOut } from '@auth/sveltekit/client';
import type { Session } from '@auth/core/types';
let { session }: { session: Session | null } = $props();
</script>
<nav>
<div class="nav-brand">
<a href="/">SvelteKit + Keycloak</a>
</div>
<div class="nav-links">
<a href="/">Home</a>
{#if session}
<a href="/dashboard">Dashboard</a>
<a href="/profile">Profile</a>
{#if session.roles?.includes('admin')}
<a href="/admin">Admin</a>
{/if}
{/if}
</div>
<div class="nav-auth">
{#if session}
<span>{session.user?.name || session.user?.email}</span>
<button onclick={() => signOut()}>Sign out</button>
{:else}
<button onclick={() => signIn('keycloak')}>Sign in</button>
{/if}
</div>
</nav>
<style>
nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: #1e293b;
color: white;
}
.nav-links {
display: flex;
gap: 1rem;
}
.nav-links a {
color: #e2e8f0;
text-decoration: none;
}
.nav-links a:hover {
color: #60a5fa;
}
button {
padding: 0.5rem 1rem;
border: 1px solid #3b82f6;
background: transparent;
color: #3b82f6;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #3b82f6;
color: white;
}
</style>
Protected Pages
Dashboard
Create src/routes/dashboard/+page.server.ts:
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
if (!session) {
throw redirect(303, '/auth/signin');
}
// Fetch data using the Keycloak access token
// In a real app, you would call your backend API here
return {
session,
dashboardData: {
totalUsers: 1234,
activeToday: 456,
lastLogin: new Date().toISOString(),
},
};
};
Create src/routes/dashboard/+page.svelte:
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>Dashboard</h1>
<div class="welcome">
<p>Welcome, {data.session?.user?.name || 'User'}</p>
<p>
Roles:
{#each data.session?.roles || [] as role}
<span class="role-badge">{role}</span>
{/each}
</p>
</div>
<div class="stats">
<div class="stat-card">
<h3>Total Users</h3>
<p>{data.dashboardData.totalUsers}</p>
</div>
<div class="stat-card">
<h3>Active Today</h3>
<p>{data.dashboardData.activeToday}</p>
</div>
</div>
<style>
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 2rem;
}
.stat-card {
background: #f8fafc;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.role-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #3b82f6;
color: white;
border-radius: 9999px;
font-size: 0.875rem;
margin: 0 0.25rem;
}
</style>
Profile Page with Form Actions
Create src/routes/profile/+page.server.ts:
import type { Actions, PageServerLoad } from './$types';
import { redirect, fail } from '@sveltejs/kit';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
if (!session) {
throw redirect(303, '/auth/signin');
}
return { session };
};
export const actions: Actions = {
updateProfile: async (event) => {
const session = await event.locals.auth();
if (!session) {
throw redirect(303, '/auth/signin');
}
const formData = await event.request.formData();
const displayName = formData.get('displayName') as string;
if (!displayName || displayName.length < 2) {
return fail(400, {
error: 'Display name must be at least 2 characters',
displayName,
});
}
// In a real app, update the user profile via Keycloak Admin API
// using the access token from the session
// const response = await fetch(
// `${KEYCLOAK_URL}/admin/realms/${REALM}/users/${session.user.id}`,
// {
// method: 'PUT',
// headers: {
// 'Authorization': `Bearer ${session.accessToken}`,
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({ firstName: displayName }),
// }
// );
return { success: true, message: 'Profile updated' };
},
};
Create src/routes/profile/+page.svelte:
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<h1>Profile</h1>
{#if form?.success}
<div class="alert success">{form.message}</div>
{/if}
{#if form?.error}
<div class="alert error">{form.error}</div>
{/if}
<div class="profile-info">
<div class="field">
<label>Email</label>
<span>{data.session?.user?.email}</span>
</div>
<div class="field">
<label>Name</label>
<span>{data.session?.user?.name}</span>
</div>
<div class="field">
<label>Roles</label>
<span>{data.session?.roles?.join(', ')}</span>
</div>
</div>
<form method="POST" action="?/updateProfile" use:enhance>
<div class="form-group">
<label for="displayName">Display Name</label>
<input
type="text"
id="displayName"
name="displayName"
value={form?.displayName || data.session?.user?.name || ''}
/>
</div>
<button type="submit">Update Profile</button>
</form>
Admin Page
Create src/routes/admin/+page.server.ts:
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
if (!session) {
throw redirect(303, '/auth/signin');
}
const roles = session.roles || [];
if (!roles.includes('admin')) {
throw redirect(303, '/unauthorized');
}
return {
session,
adminData: {
recentEvents: [
{ type: 'LOGIN', user: '[email protected]', time: '2 min ago' },
{ type: 'REGISTER', user: '[email protected]', time: '15 min ago' },
{ type: 'LOGIN_ERROR', user: '[email protected]', time: '1 hour ago' },
],
},
};
};
Create src/routes/admin/+page.svelte:
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>Admin Panel</h1>
<h2>Recent Authentication Events</h2>
<table>
<thead>
<tr>
<th>Event</th>
<th>User</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{#each data.adminData.recentEvents as event}
<tr>
<td>
<span class="event-type" class:error={event.type === 'LOGIN_ERROR'}>
{event.type}
</span>
</td>
<td>{event.user}</td>
<td>{event.time}</td>
</tr>
{/each}
</tbody>
</table>
<style>
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
.event-type {
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: #dbeafe;
color: #1e40af;
font-size: 0.875rem;
}
.event-type.error {
background: #fee2e2;
color: #dc2626;
}
</style>
Custom Sign-In Page
Create src/routes/auth/signin/+page.server.ts:
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
if (session) {
throw redirect(303, '/dashboard');
}
const callbackUrl = event.url.searchParams.get('callbackUrl') || '/dashboard';
return { callbackUrl };
};
Create src/routes/auth/signin/+page.svelte:
<script lang="ts">
import { signIn } from '@auth/sveltekit/client';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<div class="signin-container">
<div class="signin-card">
<h1>Sign In</h1>
<p>Sign in with your Keycloak account to continue.</p>
<button
class="signin-btn"
onclick={() => signIn('keycloak', { callbackUrl: data.callbackUrl })}
>
Sign in with Keycloak
</button>
<p class="help-text">
Don't have an account? Contact your administrator.
</p>
</div>
</div>
<style>
.signin-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.signin-card {
text-align: center;
padding: 3rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
max-width: 400px;
width: 100%;
}
.signin-btn {
display: block;
width: 100%;
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
margin-top: 1.5rem;
}
.signin-btn:hover {
opacity: 0.9;
}
.help-text {
margin-top: 1rem;
color: #64748b;
font-size: 0.875rem;
}
</style>
API Routes with Authentication
SvelteKit API routes can also be protected. Create src/routes/api/users/+server.ts:
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async (event) => {
const session = await event.locals.auth();
if (!session) {
throw error(401, 'Not authenticated');
}
const roles = session.roles || [];
if (!roles.includes('admin')) {
throw error(403, 'Insufficient permissions');
}
// In a real app, fetch users from your backend or Keycloak Admin API
return json({
users: [
{ id: '1', name: 'John Doe', email: '[email protected]' },
{ id: '2', name: 'Jane Smith', email: '[email protected]' },
],
});
};
If the 403 response is unexpected, consult our 403 Forbidden troubleshooting guide for a systematic debugging approach.
Token Refresh Handling
Auth.js handles token refresh automatically when you configure it in the JWT callback. Update your src/auth.ts to handle token expiration:
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
}
// Return existing token if not expired
if (token.expiresAt && Date.now() < (token.expiresAt as number) * 1000) {
return token;
}
// Token expired, try to refresh
if (token.refreshToken) {
try {
const response = await fetch(
`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: token.refreshToken as string,
client_id: process.env.KEYCLOAK_ID!,
client_secret: process.env.KEYCLOAK_SECRET!,
}),
}
);
if (!response.ok) {
throw new Error('Token refresh failed');
}
const refreshed = await response.json();
token.accessToken = refreshed.access_token;
token.refreshToken = refreshed.refresh_token ?? token.refreshToken;
token.expiresAt = Math.floor(Date.now() / 1000) + refreshed.expires_in;
return token;
} catch {
// Refresh failed, user needs to re-authenticate
return { ...token, error: 'RefreshTokenError' };
}
}
return token;
},
},
Debugging Tips
Inspect Tokens
Use the JWT Token Analyzer to decode Keycloak access tokens and verify:
- The
realm_access.rolesarray contains expected roles - The
issclaim matches yourKEYCLOAK_ISSUERenvironment variable - Token expiration (
exp) is set correctly
Check Keycloak Events
Enable event logging in Keycloak (Realm Settings > Events) to track login attempts, failures, and token grants. Keycloak’s built-in audit logging captures all authentication events.
Common Issues
- Redirect URI mismatch: Ensure
http://localhost:5173/auth/callback/keycloakis in your Keycloak client’s Valid Redirect URIs - CORS errors: Set Web Origins to
http://localhost:5173in the Keycloak client - Login loops: See our login loop troubleshooting guide
- Roles not in session: Verify the JWT callback extracts roles from the profile correctly. The structure depends on your Keycloak version and token mapper configuration.
Production Deployment
Before going to production:
- Generate a strong AUTH_SECRET: Use
npx auth secretand store it securely - Use HTTPS everywhere: Both SvelteKit and Keycloak must be behind HTTPS
- Update redirect URIs: Replace localhost URLs with production domains in Keycloak
- Configure session settings: Set appropriate session management policies in Keycloak
- Enable audit logging: Use audit logs to monitor authentication activity
- Consider managed Keycloak: Skycloak handles infrastructure, updates, and high availability so your team can focus on the application
For more on Keycloak security best practices, refer to the Keycloak Server Administration Guide and our security practices page.
Next Steps
With authentication in place, consider adding:
- Identity provider federation for Google, GitHub, or enterprise SAML login
- SCIM provisioning for automated user lifecycle management
- Custom Keycloak themes with your brand identity
- The Keycloak Config Generator for managing realm configurations as code
- Review our documentation for additional integration guides
Try Skycloak
Focus on building your SvelteKit application instead of managing Keycloak infrastructure. Skycloak provides fully managed Keycloak with automatic updates, backups, and high availability. See our pricing to get started.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.