SvelteKit
Add IdRa sign-in to a SvelteKit app with server routes and openid-client.
A SvelteKit app with server routes and openid-client. Tested with SvelteKit 3.1, Bun 1.4 and Node.js 24, through a real browser sign-in against an IdRa running locally over http. It hasn't been run over https or against idra.team.
Sample repository (being opened soon): IdRaHQ/idra-sample-sveltekit. It has this quickstart's code plus a protected page.
Register the app
Use one app for local development and production. Open your organization's admin console at https://<your-org>.idra.team/admin (or /admin on your own IdRa). Go to Applications → Create App Integration, choose OIDC - OpenID Connect, and set:
- Application type: Web Application
- App integration name: any name
- Sign-in redirect URIs:
http://localhost:5173/callback
Leave Assign this app to me so I can test it on, then select Create. Copy the client secret, shown once, and select I've saved it.
The app's Sign On tab opens:
- Under Connect your app, copy the Issuer and Client ID. The
.envsnippet there has the same values, namedOIDC_*; this quickstart names themIDRA_*. - Add
http://localhost:5173/to Sign-out redirect URIs and select Save.
To let other people sign in, assign them or their groups on Assignments.
Local redirects and production
http redirects work on localhost, 127.0.0.1 or [::1], with a port, and match exactly. PKCE stays required while the app has one. For production, add your https URIs (such as https://app.example.com/callback and https://app.example.com/) to the same app, then remove the local ones.
Configure
IDRA_ISSUER=https://<your-org>.idra.team/api/auth
IDRA_CLIENT_ID=<Client ID>
IDRA_CLIENT_SECRET=<client secret>
APP_URL=http://localhost:5173vite dev reads .env. Everywhere else, set these in the environment.
Install
bunx sv@1.1.1 create idra-sveltekit --template minimal --types ts --no-add-ons --install bun
cd idra-sveltekit
bun add openid-client@6.8.8
bun add -d @sveltejs/kit@3.1.1 svelte@5.57.3 vite@8.3.5Code
import { defineEnvVars } from '@sveltejs/kit/env';
export const variables = defineEnvVars({
IDRA_ISSUER: {},
IDRA_CLIENT_ID: {},
IDRA_CLIENT_SECRET: {},
APP_URL: {},
});import { APP_URL, IDRA_CLIENT_ID, IDRA_CLIENT_SECRET, IDRA_ISSUER } from '$app/env/private';
import type { Cookies } from '@sveltejs/kit';
import * as oidc from 'openid-client';
type Session = {
flow?: { verifier: string; state: string; nonce: string };
user?: oidc.UserInfoResponse;
idToken?: string;
};
// In memory: sessions end when the server restarts and aren't shared between processes.
// Use your database or Redis in production.
const sessions = new Map<string, Session>();
export const getSession = (cookies: Cookies) => sessions.get(cookies.get('app_session') ?? '');
export function startSession(cookies: Cookies, session: Session) {
const id = crypto.randomUUID();
sessions.set(id, session);
cookies.set('app_session', id, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: APP_URL.startsWith('https://'),
});
}
export function endSession(cookies: Cookies) {
sessions.delete(cookies.get('app_session') ?? '');
cookies.delete('app_session', { path: '/' });
}
let config: Promise<oidc.Configuration> | undefined;
export function getConfig() {
const issuer = new URL(IDRA_ISSUER);
config ??= oidc.discovery(
issuer,
IDRA_CLIENT_ID,
undefined,
oidc.ClientSecretBasic(IDRA_CLIENT_SECRET),
// Only for an IdRa you run locally on http. Never needed for an https issuer.
issuer.protocol === 'http:' ? { execute: [oidc.allowInsecureRequests] } : undefined,
);
return config;
}import { redirect } from '@sveltejs/kit';
import * as oidc from 'openid-client';
import { APP_URL } from '$app/env/private';
import { getConfig, startSession } from '#lib/server/idra.ts';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ cookies }) => {
const verifier = oidc.randomPKCECodeVerifier();
const flow = { verifier, state: oidc.randomState(), nonce: oidc.randomNonce() };
startSession(cookies, { flow });
const url = oidc.buildAuthorizationUrl(await getConfig(), {
redirect_uri: `${APP_URL}/callback`,
scope: 'openid profile email groups',
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
code_challenge_method: 'S256',
state: flow.state,
nonce: flow.nonce,
});
redirect(302, url.href, { external: true });
};import { redirect } from '@sveltejs/kit';
import * as oidc from 'openid-client';
import { APP_URL } from '$app/env/private';
import { getConfig, getSession } from '#lib/server/idra.ts';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ cookies, url }) => {
const session = getSession(cookies);
const flow = session?.flow;
if (!session || !flow) redirect(302, '/login');
try {
const config = await getConfig();
const tokens = await oidc.authorizationCodeGrant(config, new URL(url.pathname + url.search, APP_URL), {
pkceCodeVerifier: flow.verifier,
expectedState: flow.state,
expectedNonce: flow.nonce,
});
// The ID token has no email or name: they come from UserInfo.
session.user = await oidc.fetchUserInfo(config, tokens.access_token, tokens.claims()!.sub);
session.idToken = tokens.id_token;
delete session.flow;
} catch (e) {
console.error(e);
return new Response(`Sign-in failed: ${(e as Error).message}`, { status: 400 });
}
redirect(302, '/');
};import { redirect } from '@sveltejs/kit';
import * as oidc from 'openid-client';
import { APP_URL } from '$app/env/private';
import { endSession, getConfig, getSession } from '#lib/server/idra.ts';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ cookies }) => {
const idToken = getSession(cookies)?.idToken;
endSession(cookies);
if (!idToken) redirect(302, '/');
const url = oidc.buildEndSessionUrl(await getConfig(), {
id_token_hint: idToken,
post_logout_redirect_uri: `${APP_URL}/`,
});
redirect(302, url.href, { external: true });
};import { getSession } from '#lib/server/idra.ts';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ cookies }) => ({
user: getSession(cookies)?.user ?? null,
});<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<main>
{#if data.user}
<h1>Signed in</h1>
<pre>{JSON.stringify(data.user, null, 2)}</pre>
<a href="/logout" data-sveltekit-reload>Sign out</a>
{:else}
<h1>Signed out</h1>
<a href="/login" data-sveltekit-reload>Sign in with IdRa</a>
{/if}
</main>data-sveltekit-reload makes these links full page loads, because the routes redirect to another site.
The session lives in the server's memory, so the browser only holds a random ID. The allowInsecureRequests line only applies to an http issuer, such as an IdRa on your own machine.
Run
bun run devVerify sign-in and sign-out
- Open
http://localhost:5173and select Sign in with IdRa. You land on IdRa's sign-in page. - Sign in, with MFA if your policies ask for it. You come back to a Signed in page showing your claims.
- Select Sign out. You return to Signed out. This also ends the IdRa session, so the next sign-in asks for your credentials again.
Reports → System Log in the admin console shows the sign-in.
What the app receives
The ID token doesn't carry email or name. Read them from UserInfo, as this sample does.
| Claim | ID token | UserInfo | Scope |
|---|---|---|---|
sub | Yes | Yes | openid |
preferred_username (the email), updated_at | Yes | Yes | profile |
name, given_name, family_name | No | Yes | profile |
email, email_verified | No | Yes | email |
groups (group names, always including Everyone) | Yes | Yes | groups |
Add offline_access to the scopes to receive a refresh token. Access tokens last an hour. See OpenID Connect for the rest.
Troubleshooting
| What you see | Cause | Fix |
|---|---|---|
Use https on a public host, or for local development http on localhost, 127.0.0.1 or [::1] with a port (…) | The redirect URI is http on another host or without a port, or https on a loopback host. | Use http://localhost:5173/callback locally and https on a public host in production. |
Sign-in error: invalid redirect uri | The app sent a redirect URI that isn't registered. Matching is exact, including port and path. | Make it match Sign-in redirect URIs on the Sign On tab. |
Access to … denied: You are not assigned to this application. | The person isn't assigned. | Assign them on Assignments. |
Access to … denied: This application is currently inactive. | The app is deactivated. | Activate it on the app's page. |
Sign-in error: client not found, or once signed in Access denied: This application is not registered with your organization. | Wrong client ID, or a client ID from another organization. | Copy the Client ID from Sign On. |
Access denied: Your organization's sign-on policy does not allow this access., URL has code=invalid_scope | The app asked for a scope that doesn't exist. | Use openid, profile, email, groups, offline_access or a scope defined under Security → API. |
pkce is required for this client | The authorize request has no PKCE challenge. | Send code_challenge with code_challenge_method=S256. IdRa accepts only S256. |
Sign-in failed: server responded with a challenge in the WWW-Authenticate HTTP Header | openid-client's message for a wrong secret, or one that was rotated. | Use Generate new secret on Sign On. |
invalid_client: client registered for client_secret_basic cannot use client_secret_post | The library sent the secret in the body. | Send it as HTTP Basic, or change Client authentication on Sign On. |
invalid_grant: redirect_uri mismatch | The token request's redirect URI differs from the authorize request's. | Use the same value in both. |
invalid_grant: code verification failed | The PKCE verifier doesn't match the challenge. | Keep the verifier from the authorize step, per sign-in. |
404 on discovery | The issuer is missing /api/auth. | Use the Issuer from Sign On: https://<your-org>.idra.team/api/auth. |
Page says Logged out. The requested post-logout redirect was not registered. | The person was signed out, but the return URL isn't registered. | Add it to Sign-out redirect URIs. |
| Confirm logout page | No id_token_hint, or it's for a different session than the browser's. | Send the ID token as the hint. Without one, the page is the expected safeguard. |
Reports → System Log records refused access as app.access with the reason.