Overview
This guide walks through adding AuthPI authentication to a SvelteKit application. Every code section shows two approaches side by side:
- AuthPI SDK (
@authpi/idp) — the official SDK with built-in PKCE, token refresh, and agent management. - Arctic (
arctic) — a lightweight, generic OAuth 2.0 client where you wire up endpoints yourself.
Both implement the Authorization Code flow with PKCE (S256), using SvelteKit server routes (+server.ts) and a server load function so tokens never reach the browser.
Prerequisites
- An AuthPI account with an Issuer configured
- A registered Client (Web type) with redirect URI set to
http://localhost:5173/auth/callback - Your Client ID and Client Secret noted
- A test User created in your Issuer
Install
npm install @authpi/idp npm install arctic Environment variables
Create a .env file in your project root. SvelteKit exposes non-public variables to server code via $env/static/private, and PUBLIC_-prefixed ones to both server and browser via $env/static/public:
AUTHPI_ISSUER_URL=https://idp.authpi.com/i_your_issuer_id
AUTHPI_CLIENT_ID=c_your_client_id
AUTHPI_CLIENT_SECRET=your_client_secret
PUBLIC_BASE_URL=http://localhost:5173Configure the client
// src/lib/server/auth.ts
import { IdpClient } from "@authpi/idp";
import { AUTHPI_ISSUER_URL, AUTHPI_CLIENT_ID, AUTHPI_CLIENT_SECRET } from "$env/static/private";
import { PUBLIC_BASE_URL } from "$env/static/public";
export const authClient = new IdpClient({
issuerUrl: AUTHPI_ISSUER_URL,
clientId: AUTHPI_CLIENT_ID,
clientSecret: AUTHPI_CLIENT_SECRET,
redirectUri: `${PUBLIC_BASE_URL}/auth/callback`,
}); // src/lib/server/auth.ts
import * as arctic from "arctic";
import { AUTHPI_ISSUER_URL, AUTHPI_CLIENT_ID, AUTHPI_CLIENT_SECRET } from "$env/static/private";
import { PUBLIC_BASE_URL } from "$env/static/public";
export const issuerUrl = AUTHPI_ISSUER_URL;
export const oauthClient = new arctic.OAuth2Client(
AUTHPI_CLIENT_ID,
AUTHPI_CLIENT_SECRET,
`${PUBLIC_BASE_URL}/auth/callback`
); Login route
Redirect the user to AuthPI's authorization endpoint. Store the PKCE code verifier and state in httpOnly cookies so they survive the redirect. SvelteKit requires an explicit path on every cookie.
// src/routes/auth/login/+server.ts
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";
import { authClient } from "$lib/server/auth";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ cookies }) => {
const { url, codeVerifier, state } = await authClient.createAuthorizationUrl({
scopes: ["openid", "profile", "email"],
});
const cookieOptions = { httpOnly: true, secure: !dev, path: "/", maxAge: 60 * 10, sameSite: "lax" as const };
cookies.set("code_verifier", codeVerifier, cookieOptions);
cookies.set("oauth_state", state, cookieOptions);
redirect(302, url);
}; // src/routes/auth/login/+server.ts
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";
import * as arctic from "arctic";
import { oauthClient, issuerUrl } from "$lib/server/auth";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ cookies }) => {
const state = arctic.generateState();
const codeVerifier = arctic.generateCodeVerifier();
const url = oauthClient.createAuthorizationURLWithPKCE(
`${issuerUrl}/authorize`,
state,
arctic.CodeChallengeMethod.S256,
codeVerifier,
["openid", "profile", "email"]
);
const cookieOptions = { httpOnly: true, secure: !dev, path: "/", maxAge: 60 * 10, sameSite: "lax" as const };
cookies.set("code_verifier", codeVerifier, cookieOptions);
cookies.set("oauth_state", state, cookieOptions);
redirect(302, url.toString());
}; Callback route
Exchange the authorization code for tokens, then store them in a session cookie. In production you should encrypt this cookie — here we keep it simple with JSON.
// src/routes/auth/callback/+server.ts
import { redirect, error } from "@sveltejs/kit";
import { dev } from "$app/environment";
import { authClient } from "$lib/server/auth";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ url, cookies }) => {
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const storedState = cookies.get("oauth_state");
const codeVerifier = cookies.get("code_verifier");
if (!code || !state || !storedState || !codeVerifier || state !== storedState) {
error(400, "Invalid callback parameters");
}
const agent = await authClient.exchangeCode(code, codeVerifier);
cookies.set("session", JSON.stringify(agent.tokens), {
httpOnly: true,
secure: !dev,
path: "/",
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: "lax",
});
cookies.delete("code_verifier", { path: "/" });
cookies.delete("oauth_state", { path: "/" });
redirect(302, "/dashboard");
}; // src/routes/auth/callback/+server.ts
import { redirect, error } from "@sveltejs/kit";
import { dev } from "$app/environment";
import { oauthClient, issuerUrl } from "$lib/server/auth";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ url, cookies }) => {
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const storedState = cookies.get("oauth_state");
const codeVerifier = cookies.get("code_verifier");
if (!code || !state || !storedState || !codeVerifier || state !== storedState) {
error(400, "Invalid callback parameters");
}
const tokens = await oauthClient.validateAuthorizationCode(
`${issuerUrl}/token`,
code,
codeVerifier
);
const sessionData = {
accessToken: tokens.accessToken(),
refreshToken: tokens.refreshToken(),
idToken: tokens.idToken(),
expiresAt: tokens.accessTokenExpiresAt().toISOString(),
};
cookies.set("session", JSON.stringify(sessionData), {
httpOnly: true,
secure: !dev,
path: "/",
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: "lax",
});
cookies.delete("code_verifier", { path: "/" });
cookies.delete("oauth_state", { path: "/" });
redirect(302, "/dashboard");
}; Protected page
Read the session cookie in a server load function and pass user information to the page. The AuthPI SDK can reconstitute an agent from stored tokens and will auto-refresh expired access tokens. With arctic, you fetch the userinfo endpoint manually.
// src/routes/dashboard/+page.server.ts
import { redirect } from "@sveltejs/kit";
import { authClient } from "$lib/server/auth";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ cookies }) => {
const sessionCookie = cookies.get("session");
if (!sessionCookie) {
redirect(302, "/auth/login");
}
const tokens = JSON.parse(sessionCookie);
const agent = await authClient.createAgent(tokens);
return { userId: agent.id, email: agent.email };
}; // src/routes/dashboard/+page.server.ts
import { redirect } from "@sveltejs/kit";
import { issuerUrl } from "$lib/server/auth";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ cookies }) => {
const sessionCookie = cookies.get("session");
if (!sessionCookie) {
redirect(302, "/auth/login");
}
const session = JSON.parse(sessionCookie);
const response = await fetch(`${issuerUrl}/userinfo`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
if (!response.ok) {
redirect(302, "/auth/login");
}
const user = await response.json();
return { userId: user.sub, email: user.email };
}; Render the returned data in the page component:
<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<main>
<h1>Dashboard</h1>
<p>User ID: {data.userId}</p>
<p>Email: {data.email}</p>
<a href="/auth/logout">Log out</a>
</main>Logout route
Revoke the refresh token, clear the session cookie, and redirect to AuthPI's logout endpoint so the IdP session is also terminated.
// src/routes/auth/logout/+server.ts
import { redirect } from "@sveltejs/kit";
import { authClient } from "$lib/server/auth";
import { PUBLIC_BASE_URL } from "$env/static/public";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ cookies }) => {
const sessionCookie = cookies.get("session");
if (sessionCookie) {
const tokens = JSON.parse(sessionCookie);
if (tokens.refreshToken) {
await authClient.revokeToken(tokens.refreshToken, "refresh_token");
}
}
const logoutUrl = authClient.createLogoutUrl({
postLogoutRedirectUri: PUBLIC_BASE_URL,
});
cookies.delete("session", { path: "/" });
redirect(302, logoutUrl);
}; // src/routes/auth/logout/+server.ts
import { redirect } from "@sveltejs/kit";
import { oauthClient, issuerUrl } from "$lib/server/auth";
import { PUBLIC_BASE_URL } from "$env/static/public";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ cookies }) => {
const sessionCookie = cookies.get("session");
if (sessionCookie) {
const session = JSON.parse(sessionCookie);
if (session.refreshToken) {
await oauthClient.revokeToken(`${issuerUrl}/revoke`, session.refreshToken);
}
}
const logoutUrl = new URL(`${issuerUrl}/logout`);
logoutUrl.searchParams.set("post_logout_redirect_uri", PUBLIC_BASE_URL);
cookies.delete("session", { path: "/" });
redirect(302, logoutUrl.toString());
}; Next steps
You now have a working login/logout flow. From here you can:
- Add Organizations support for multi-tenant access control
- Subscribe to Events for audit logging
- Set up Webhooks for real-time notifications when users sign up, update their profile, or change authentication methods
The AuthPI SDK handles token refresh automatically via createAgent(). If you are using arctic, you will need to implement refresh logic manually by calling the /token endpoint with grant_type=refresh_token when the access token expires.