SvelteKit
@loginwith/sveltekit — server-side sessions (BFF), or a browser client.
npm add @loginwith/sveltekit
Two modes, two entry points. Import the one you mean:
| Import | Mode | Tokens live |
|---|---|---|
@loginwith/sveltekit/server | BFF — your server runs the sign-in | In an encrypted, httpOnly cookie. Never in the browser. |
@loginwith/sveltekit/client | CSR — the tab runs the sign-in | In localStorage. See Browser SDK. |
The package root re-exports /client, for older imports. It never re-exports
/server, so server code cannot end up in a browser bundle by accident.
Server mode (BFF)
Configure
// src/lib/server/auth.ts
import { createSvelteKitAuth } from "@loginwith/sveltekit/server"
import { env } from "$env/dynamic/private"
export const auth = createSvelteKitAuth({
api: "https://acme.loginwith.dev",
issuer: "https://acme.loginwith.dev/", // trailing slash
clientId: env.LGW_CLIENT_ID,
clientSecret: env.LGW_CLIENT_SECRET, // a confidential client
loginPage: "https://acme.loginwith.page/",
sessionSecret: env.LGW_SESSION_SECRET, // openssl rand -base64 32
})
| Option | Required | Default | |
|---|---|---|---|
api | yes | Where API requests go — https://acme.loginwith.dev. | |
issuer | yes | iss exactly as your tokens carry it: https://acme.loginwith.dev/, trailing slash included. | |
clientId | yes | The client your app signs in as. Its tokens carry it as aud. | |
clientSecret | A confidential client’s secret, sent in the token request body (client_secret_post). Omit for a public client. | ||
sessionSecret | yes | Your key for encrypting the session cookie. | |
loginPage | yes | Where sign-in starts — https://acme.loginwith.page/ or your custom domain. | |
loginPath | /lgw/login | The path that starts sign-in. | |
callbackPath | /lgw/callback | The path the issuer redirects back to. | |
secure | true | The cookies’ Secure attribute. Set false only for http://localhost. | |
sessionTtl | 30 days | Session cookie lifetime, in seconds. | |
cookieNames | { session: "lgw_session", transaction: "lgw_tx" } | Rename either cookie. | |
hostOverride | Development only: forces the org via ?host= against a localhost issuer. | ||
switchTenantGrant | urn:loginwith:params:oauth:grant-type:switch-tenant | The tenant-switch grant URN. Leave it unless your issuer is configured otherwise. |
Wire it
Four files:
// src/hooks.server.ts → export const { handle } = auth
// src/routes/lgw/login/+server.ts → export const GET = auth.login
// src/routes/lgw/callback/+server.ts → export const GET = auth.callback
// src/routes/lgw/logout/+server.ts → export const POST = auth.logout
Already have a hook? Compose it with SvelteKit’s sequence:
// src/hooks.server.ts
import { sequence } from "@sveltejs/kit/hooks"
import { auth } from "$lib/server/auth"
export const handle = sequence(auth.handle, yourHandle)
Register https://<your app>/lgw/callback as a redirect URI on the client in
the console. The redirect URI is built per request from the request’s origin
plus callbackPath, so behind a proxy make sure SvelteKit sees your public
origin (for adapter-node, set ORIGIN).
Routes
| Handler | Does |
|---|---|
auth.login | Mints a PKCE verifier, seals it in the lgw_tx cookie, and redirects to loginPage. ?return_to=/path sets where to land afterwards — a path on your origin only; anything else is ignored. |
auth.callback | Redeems the code (or a login-page voucher), verifies the token, sets the session cookie, clears lgw_tx, and redirects to return_to (default /). On failure, redirects to loginPath?error=<code> — an HTTP status or sign_in_failed, never the reason’s detail. |
auth.logout | Revokes the session at the issuer (best-effort), clears the session cookie, redirects to /. A GET works too. |
The routes are +server.ts endpoints, not pages, so link to them with a full
navigation:
<a href="/lgw/login?return_to=/dashboard" data-sveltekit-reload>Sign in</a>
<form method="POST" action="/lgw/logout">
<button>Sign out</button>
</form>
Read the session
Declare what handle sets:
// src/app.d.ts
import type { LoginWithLocals } from "@loginwith/sveltekit/server"
declare global {
namespace App {
interface Locals { loginwith: LoginWithLocals }
}
}
export {}
locals.loginwith is { token, claims } for a signed-in request, null
otherwise. token is the access token — send it as Authorization: Bearer to
LoginWith or your own API. claims is its payload (sub the user id, tnt
the tenant, exp, …).
// src/routes/dashboard/+page.server.ts
import { redirect } from "@sveltejs/kit"
export const load = async ({ locals, fetch }) => {
const session = locals.loginwith
if (!session) redirect(302, "/lgw/login?return_to=/dashboard")
const res = await fetch("https://api.acme.com/projects", {
headers: { authorization: `Bearer ${session.token}` },
})
return { userId: session.claims.sub, projects: await res.json() }
}
The cookies
lgw_session | lgw_tx | |
|---|---|---|
| Holds | Access token, refresh token, expiry | PKCE verifier, return_to |
| Encryption | AES-256-GCM (WebCrypto), key = SHA-256 of sessionSecret | Same |
| Attributes | httpOnly, SameSite=Lax, Secure, Path=/ | Same |
| Lifetime | sessionTtl (30 days) | 10 minutes — one sign-in |
Encrypted, not signed: the tokens cannot be read out of the cookie, not just not altered. A cookie that does not decrypt — wrong key, tampered — is treated as no session. WebCrypto only, so it runs on edge runtimes too.
SameSite=Lax, never Strict: the callback is a cross-site navigation, and
Strict would withhold the transaction cookie there.
Refresh is transparent. On each request handle opens the cookie. When the
access token is within 10 seconds of expiry, it spends the refresh token,
verifies the new access token, and re-sets the cookie in the same response. If
the issuer refuses the refresh, the cookie is cleared and locals.loginwith is
null. If the issuer cannot be reached, the cookie is left for the next
request — a network blip does not sign anyone out.
Machine-to-machine
A confidential client can mint a token for the app itself — no user:
const { accessToken } = await auth.clientCredentials()
It throws if clientSecret is not set, and it is not cached. For a cached,
single-flight minter, see createTokenMinter.
auth.core is the framework-agnostic core underneath, for anything the three
routes do not cover.
Client mode (CSR)
import { createLoginWith } from "@loginwith/sveltekit/client"
const LoginWith = createLoginWith({
api: "https://acme.loginwith.dev",
issuer: "https://acme.loginwith.dev/", // trailing slash
clientId: "cli_…",
loginPage: "https://acme.loginwith.page/",
})
const lgw = new LoginWith(window)
The tab holds the tokens and calls your API with await lgw.getAccessToken().
Nothing in it is SvelteKit-specific: methods, the callback route and storage
are on Browser SDK. It needs window, so construct it in
the browser only — in onMount, or behind browser from $app/environment.