SvelteKit Integration

SvelteKit Integration

This guide covers how to integrate Skycloak authentication into SvelteKit applications using @auth/sveltekit (Auth.js) with a generic OpenID Connect provider configuration.

Prerequisites

  • SvelteKit 2.x application
  • Node.js 18+
  • Skycloak cluster with configured realm and client
  • Basic understanding of SvelteKit hooks and load functions

Quick Start

1. Create an Application in Skycloak

  1. In Skycloak, navigate to your cluster → ApplicationsCreate Application
  2. Set Application Type to Confidential (Auth.js exchanges the authorization code server-side)
  3. Add Redirect URIs: http://localhost:5173/auth/callback/skycloak for local dev, plus your production URL
  4. After creation, copy the Client ID and Client Secret from the credentials tab

2. Install Dependencies

npm install @auth/sveltekit @auth/core

3. Configure Environment Variables

Create .env:

AUTH_SECRET=a-random-32-char-string-generated-with-openssl-rand
AUTH_TRUST_HOST=true

SKYCLOAK_CLIENT_ID=your-sveltekit-app
SKYCLOAK_CLIENT_SECRET=your-client-secret
SKYCLOAK_ISSUER=https://your-cluster-id.app.skycloak.io/realms/your-realm

Generate AUTH_SECRET with:

openssl rand -base64 32

4. Configure Auth.js with a Generic OIDC Provider

Create src/auth.ts:

import { SvelteKitAuth } from '@auth/sveltekit'
import type { Provider } from '@auth/core/providers'
import { env } from '$env/dynamic/private'

function SkycloakProvider(): Provider {
  return {
    id: 'skycloak',
    name: 'Skycloak',
    type: 'oidc',
    issuer: env.SKYCLOAK_ISSUER,
    clientId: env.SKYCLOAK_CLIENT_ID,
    clientSecret: env.SKYCLOAK_CLIENT_SECRET,
    authorization: { params: { scope: 'openid profile email' } },
    checks: ['pkce', 'state'],
    profile(profile) {
      return {
        id: profile.sub,
        name: profile.name,
        email: profile.email,
        realmRoles: profile.realm_access?.roles ?? [],
      }
    },
  }
}

export const { handle, signIn, signOut } = SvelteKitAuth({
  providers: [SkycloakProvider()],
  trustHost: true,
  callbacks: {
    async jwt({ token, account, profile }) {
      if (account) {
        token.accessToken = account.access_token
        token.idToken = account.id_token
        token.refreshToken = account.refresh_token
        token.realmRoles = (profile as { realm_access?: { roles?: string[] } })
          ?.realm_access?.roles ?? []
      }
      return token
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken as string
      session.realmRoles = (token.realmRoles as string[]) ?? []
      return session
    },
  },
})

5. Wire Up the Server Hook

// src/hooks.server.ts
import { handle as authHandle } from './auth'

export const handle = authHandle

6. Add Login/Logout Controls

<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { signIn, signOut } from '@auth/sveltekit/client'
  export let data
</script>

{#if data.session?.user}
  <span>Welcome, {data.session.user.name}</span>
  <button on:click={() => signOut()}>Sign Out</button>
{:else}
  <button on:click={() => signIn('skycloak')}>Sign In with Skycloak</button>
{/if}
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types'

export const load: LayoutServerLoad = async (event) => {
  return { session: await event.locals.auth() }
}

Protecting Routes

Server Load Function Guard

// src/routes/dashboard/+layout.server.ts
import { redirect } from '@sveltejs/kit'
import type { LayoutServerLoad } from './$types'

export const load: LayoutServerLoad = async (event) => {
  const session = await event.locals.auth()

  if (!session?.user) {
    throw redirect(303, '/auth/signin')
  }

  return { session }
}

Role-Based Guard in hooks.server.ts

// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks'
import { handle as authHandle } from './auth'
import { redirect, type Handle } from '@sveltejs/kit'

const requireAdmin: Handle = async ({ event, resolve }) => {
  if (event.url.pathname.startsWith('/admin')) {
    const session = await event.locals.auth()
    const roles = (session as { realmRoles?: string[] })?.realmRoles ?? []

    if (!roles.includes('admin')) {
      throw redirect(303, '/unauthorized')
    }
  }

  return resolve(event)
}

export const handle = sequence(authHandle, requireAdmin)

Protected API Endpoint

// src/routes/api/orders/+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?.user) {
    throw error(401, 'Unauthorized')
  }

  const response = await fetch('https://api.example.com/orders', {
    headers: { Authorization: `Bearer ${session.accessToken}` },
  })

  return json(await response.json())
}

Production Considerations

  • Set AUTH_SECRET, SKYCLOAK_CLIENT_SECRET, and SKYCLOAK_ISSUER through your hosting platform’s secret store, not committed .env files.
  • AUTH_TRUST_HOST=true is required behind most reverse proxies (Vercel, Fly.io, containers) so Auth.js trusts the forwarded host header — only enable it once your deployment terminates TLS and controls that header.
  • Redirect URIs must match exactly what’s registered on the Application in Skycloak, including the /auth/callback/skycloak path Auth.js expects for the skycloak provider id.

Troubleshooting

  1. OAuthCallbackError / redirect URI mismatch — the callback path is always /auth/callback/<provider id>; since the provider id here is skycloak, the registered Redirect URI must end in /auth/callback/skycloak.
  2. session.accessToken is undefined — confirm the jwt callback in src/auth.ts runs on first sign-in (account is only present on that first call) and that session mapping reads from token, not account.
  3. Roles missing from session — realm roles arrive in the ID token’s realm_access.roles claim only if the client’s scope includes it by default in Skycloak; check the client’s assigned scopes if profile.realm_access is undefined.

Next Steps

Last updated on