SvelteKit Authentication with Keycloak: Complete Guide

Guilliano Molaire Guilliano Molaire Updated April 6, 2026 12 min read

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

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

  1. Log into http://localhost:8080/admin
  2. Create a realm called sveltekit-app
  3. Go to Clients > Create client:
    • Client type: OpenID Connect
    • Client ID: sveltekit-client
  4. Capability Config:
    • Client authentication: On (confidential client, since Auth.js runs server-side)
    • Standard flow: Enabled
    • Direct access grants: Disabled
  5. Login Settings:
    • Valid redirect URIs: http://localhost:5173/*
    • Valid post logout redirect URIs: http://localhost:5173/*
    • Web origins: http://localhost:5173
  6. After saving, go to the Credentials tab and copy the client secret

Create Roles and a Test User

  1. Go to Realm roles > create user and admin roles
  2. Go to Users > create a test user with a password
  3. Assign the user role 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.roles array contains expected roles
  • The iss claim matches your KEYCLOAK_ISSUER environment 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

  1. Redirect URI mismatch: Ensure http://localhost:5173/auth/callback/keycloak is in your Keycloak client’s Valid Redirect URIs
  2. CORS errors: Set Web Origins to http://localhost:5173 in the Keycloak client
  3. Login loops: See our login loop troubleshooting guide
  4. 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:

  1. Generate a strong AUTH_SECRET: Use npx auth secret and store it securely
  2. Use HTTPS everywhere: Both SvelteKit and Keycloak must be behind HTTPS
  3. Update redirect URIs: Replace localhost URLs with production domains in Keycloak
  4. Configure session settings: Set appropriate session management policies in Keycloak
  5. Enable audit logging: Use audit logs to monitor authentication activity
  6. 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:

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.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman