Skip to main content

SvelteKit

View a live deployed version.

info

We will use the Phase Two SvelteKit example code here, but the logic could easily be applied to any existing application.

The example is a SvelteKit 2 app with Svelte 5. It logs users in with Auth.js (@auth/sveltekit 1.11) and its Keycloak provider. The login runs on the server: the tokens stay in an encrypted, HTTP-only session cookie, and the browser only sees their decoded claims. You need Node.js 24 and pnpm.

  1. Clone the Phase Two example repo if you haven't yet, and open the SvelteKit folder:

    git clone https://github.com/p2-inc/examples.git
    cd examples/frameworks/sveltekit
  2. Copy .env.example to .env:

    cp .env.example .env

    .env.example points at the local Keycloak of the example repo. Its p2examples realm already has the example's sveltekit client and a non-admin user, demo / demo. If it isn't running yet, start it from the root of the repo with docker compose -f keycloak/docker-compose.yml up -d --wait.

    Auth.js reads these variables at runtime:

    VariableDescriptionLocal Keycloak
    AUTH_SECRETRandom secret that encrypts the sessionGenerate your own
    AUTH_KEYCLOAK_IDClient IDsveltekit
    AUTH_KEYCLOAK_SECRETClient secretsveltekit-local-dev-secret
    AUTH_KEYCLOAK_ISSUERIssuer URL of the realmhttp://localhost:8080/auth/realms/p2examples

    Set AUTH_SECRET in .env to a random value, such as the output of openssl rand -base64 32.

    To use another Keycloak, set AUTH_KEYCLOAK_ISSUER to your realm's issuer URL, and AUTH_KEYCLOAK_ID and AUTH_KEYCLOAK_SECRET to the ID and secret of a confidential client there, with http://localhost:3000/* as valid redirect URI and + as valid post logout redirect URI. Use your own client's ID, since sveltekit is only the client of the local Keycloak. .env is ignored by git, so your secrets stay out of version control.

  3. Install the dependencies and start the app:

    pnpm install
    pnpm dev
  4. The project makes use of the following SvelteKit items: a server hook, a layout load function, form actions and the @auth/sveltekit module. We'll review each in turn.

  5. Open src/auth.ts, where the example configures Auth.js. This file runs only on the server and reads the Keycloak settings at runtime through $env/dynamic/private. At the top of src/auth.ts, refreshAccessToken trades the refresh token for new tokens at Keycloak's token endpoint:

    import type { JWT } from '@auth/core/jwt';
    import { SvelteKitAuth } from '@auth/sveltekit';
    import Keycloak from '@auth/sveltekit/providers/keycloak';
    import { env } from '$env/dynamic/private';
    import { decodeJwtPayload } from '$lib/server/jwt';

    async function refreshAccessToken(token: JWT): Promise<JWT> {
    if (!token.refreshToken) {
    return { ...token, error: 'RefreshAccessTokenError' };
    }

    const response = await fetch(`${env.AUTH_KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
    method: 'POST',
    body: new URLSearchParams({
    grant_type: 'refresh_token',
    client_id: env.AUTH_KEYCLOAK_ID ?? '',
    client_secret: env.AUTH_KEYCLOAK_SECRET ?? '',
    refresh_token: token.refreshToken
    })
    });

    if (!response.ok) {
    return { ...token, error: 'RefreshAccessTokenError' };
    }

    const tokens = await response.json();
    return {
    ...token,
    accessToken: tokens.access_token,
    idToken: tokens.id_token ?? token.idToken,
    refreshToken: tokens.refresh_token ?? token.refreshToken,
    expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in,
    error: undefined
    };
    }

    Further down in src/auth.ts, SvelteKitAuth creates the handle hook and the signIn and signOut actions:

    export const { handle, signIn, signOut } = SvelteKitAuth({
    trustHost: true,
    providers: [Keycloak],
    callbacks: {
    async jwt({ token, account }) {
    if (account) {
    return {
    ...token,
    accessToken: account.access_token,
    idToken: account.id_token,
    refreshToken: account.refresh_token,
    expiresAt: account.expires_at
    };
    }

    if (token.expiresAt && Date.now() < (token.expiresAt - 30) * 1000) {
    return token;
    }

    return refreshAccessToken(token);
    },
    session({ session, token }) {
    return {
    ...session,
    error: token.error,
    accessTokenClaims: decodeJwtPayload(token.accessToken),
    idTokenClaims: decodeJwtPayload(token.idToken)
    };
    }
    },
    events: {
    async signOut(message) {
    const refreshToken = 'token' in message ? message.token?.refreshToken : undefined;
    if (!refreshToken) {
    return;
    }

    await fetch(`${env.AUTH_KEYCLOAK_ISSUER}/protocol/openid-connect/logout`, {
    method: 'POST',
    body: new URLSearchParams({
    client_id: env.AUTH_KEYCLOAK_ID ?? '',
    client_secret: env.AUTH_KEYCLOAK_SECRET ?? '',
    refresh_token: refreshToken
    })
    });
    }
    }
    });
    • providers: [Keycloak] passes the provider without options: Auth.js reads its client ID, client secret and issuer from the AUTH_KEYCLOAK_* variables, and its own secret from AUTH_SECRET.
    • The jwt callback stores Keycloak's access, ID and refresh tokens in the encrypted session cookie when the user logs in. When the access token has less than 30 seconds left, it calls refreshAccessToken.
    • The session callback decides what the app gets from the session: the decoded claims of the access and ID tokens, and an error if the refresh failed. The tokens themselves never leave the server.
    • The signOut event posts the refresh token to Keycloak's logout endpoint, so logging out of the app also ends the Keycloak session.

    decodeJwtPayload is in src/lib/server/jwt.ts, and src/app.d.ts adds these fields to the Session and JWT types.

  6. Next, let's review the hooks file. SvelteKit runs its handle hook on every request, so Auth.js can answer its own routes under /auth, such as the callback that Keycloak redirects to after the login, and add auth() to event.locals. src/hooks.server.ts re-exports handle from src/auth.ts:

    export { handle } from './auth';
  7. To get the session during server-side rendering, src/routes/+layout.server.ts calls event.locals.auth() from SvelteKit Locals and returns the session to every page:

    import type { LayoutServerLoad } from './$types';

    export const load: LayoutServerLoad = async (event) => {
    return { session: await event.locals.auth() };
    };
  8. src/routes/+page.svelte receives that data with $props() and passes the session on to the UserStatus component:

    <script lang="ts">
    import Footer from '$lib/components/Footer.svelte';
    import Header from '$lib/components/Header.svelte';
    import UserStatus from '$lib/components/UserStatus.svelte';

    let { data } = $props();
    </script>

    <div class="page-bg min-h-screen">
    <Header />
    <main class="py-8">
    <div class="mx-auto max-w-3xl px-6 text-center lg:px-8">
    <UserStatus session={data.session} />
    </div>
    </main>
    <Footer />
    </div>
  9. UserStatus shows Not authenticated. and a Log in button, or your name and email, the decoded tokens and a Log out button. If the token refresh failed, it asks you to log in again. In src/lib/components/UserStatus.svelte, the buttons are plain HTML forms that post to /signin and /signout:

    <script lang="ts">
    import type { Session } from '@auth/sveltekit';
    import TokenPanels from './TokenPanels.svelte';

    let { session }: { session: Session | null } = $props();

    const buttonClasses =
    'cursor-pointer rounded-md bg-indigo-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600';
    </script>

    {#snippet loginForm()}
    <form method="POST" action="/signin">
    <input type="hidden" name="providerId" value="keycloak" />
    <input type="hidden" name="redirectTo" value="/" />
    <button type="submit" class={buttonClasses}>Log in</button>
    </form>
    {/snippet}

    <div>
    <div class="pb-8 text-xl italic">Your current status is:</div>
    {#if session?.error}
    <div class="mb-2 text-2xl text-p2blue-700">Authentication error.</div>
    <div class="mb-6">Your session expired. Please log in again.</div>
    {@render loginForm()}
    {:else if session}
    <div class="mb-2 text-2xl text-p2blue-700">Authenticated</div>
    <div class="mb-6 text-p2blue-700">
    <div>{session.user?.name}</div>
    <div>{session.user?.email}</div>
    </div>
    <form method="POST" action="/signout">
    <input type="hidden" name="redirectTo" value="/" />
    <button type="submit" class={buttonClasses}>Log out</button>
    </form>
    <TokenPanels {session} />
    {:else}
    <div class="mb-6 text-2xl text-p2blue-700">Not authenticated.</div>
    {@render loginForm()}
    {/if}
    </div>

    Each of those routes only has a form action: the signIn or signOut action from src/auth.ts. In src/routes/signin/+page.server.ts:

    import { signIn } from '../../auth';
    import type { Actions } from './$types';

    export const actions = { default: signIn } satisfies Actions;

    src/routes/signout/+page.server.ts does the same with signOut. The hidden providerId field makes signIn go straight to Keycloak, and redirectTo brings you back to the home page.

    Protect other routes the same way: call event.locals.auth() in their load functions and actions, and check that it returns a session.

  10. Open localhost:3000. You will see the Phase Two example landing page. Your current status should be Not authenticated. Click Log in. This will redirect you to the Keycloak login page.

    info

    Sign in with a non-admin user, such as demo / demo on the local Keycloak.

  11. Enter the user's credentials and sign in. You will then be redirected to the application. The Phase Two example landing page now shows your Authenticated state, your user's name and email, and the decoded access token and ID token.

  12. Click Log out. The app also ends your Keycloak session, so the next time you click Log in, Keycloak asks for your credentials again.