A Complete Guide to JWT Authentication in SvelteKit
An easy-to-follow complete guide on JWT authentication in SvelteKit—with example project!

JWT vs Session Authentication in SvelteKit
When building authentication for a modern web app, one of the first architectural choices you'll face is JWTs vs. sessions. Both approaches have trade-offs:
Session-based authentication stores a session record in a database or cache, with a cookie that points to that record.
- ✅ Easy to revoke instantly; server controls session lifecycle
- ✅ Keeps sensitive data server-side
- ❌ Requires a DB/cache hit on every request, which adds latency and infrastructure complexity
- ❌ Scaling horizontally can require sticky sessions or distributed storage
JWT-based authentication encodes the user identity directly in a signed token stored in a cookie.
- ✅ Stateless and fast to verify — no DB lookup required
- ✅ Scales cleanly across multiple servers or serverless environments
- ❌ Revoking tokens before expiration requires extra infrastructure (e.g. a revocation list)
- ❌ Tokens are larger than a session ID, and must be secured carefully
This guide takes the JWT path, aiming for a pragmatic, production-minded setup in SvelteKit. We'll cover how tokens are issued, verified, and revoked, how to store minimal user state securely in cookies, and how to protect routes with SvelteKit's handle
hook.
What we'll build
- Stateless authentication with signed JWTs using
jose
- Signed,
httpOnly
session cookie namedtoken
- A lightweight, signed-in user snapshot in a readable
userData
cookie - Route protection and redirect logic via
hooks.server.ts
- Simple helpers to log in, log out, and verify requests
1) Issuing and validating JWTs
All JWT-related logic lives in src/lib/server/jwt.ts
. This file handles creating, verifying, and clearing tokens.
// src/lib/server/jwt.ts
import { SignJWT, jwtVerify } from 'jose';
import { type Cookies } from '@sveltejs/kit';
const jwtExpires = 60 * 60 * 24 * 14; // 14 days
function getJwtSecretKey() {
const key = process.env.JWT_SECRET;
if (!key) throw new Error('JWT_SECRET not set');
return new TextEncoder().encode(key);
}
export async function setJwt(cookies: Cookies, tokenData: Record<string, unknown>) {
const token = await new SignJWT(tokenData)
.setProtectedHeader({ alg: 'HS512' })
.setIssuedAt()
.setExpirationTime(`${jwtExpires}s`)
.sign(getJwtSecretKey());
cookies.set('token', token, {
path: '/',
sameSite: 'lax',
secure: true,
httpOnly: true,
expires: new Date(Date.now() + jwtExpires * 1000)
});
}
export async function verifyJwtToken(token: string | undefined) {
if (!token) return null;
try {
const { payload } = await jwtVerify(token, getJwtSecretKey());
return payload as { sub?: string } & Record<string, unknown>;
} catch {
return null;
}
}
Key points:
- Tokens are signed with HS512 and expire after 14 days.
- Stored in an
httpOnly
,secure
cookie calledtoken
. - Verification is just a cryptographic check — no DB needed.
2) Storing a safe, readable user snapshot
For convenience in the UI, we also set a userData
cookie that holds non-sensitive information like id
and email
.
// src/lib/server/jwt.ts
export function setUserDataCookie(cookies: Cookies, userData: Record<string, unknown>) {
cookies.set('userData', JSON.stringify(userData), {
path: '/',
sameSite: 'lax',
secure: false,
httpOnly: false,
expires: new Date(Date.now() + jwtExpires * 1000)
});
}
export function getUserDataFromCookie(cookies: Cookies) {
const raw = cookies.get('userData');
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
Guidelines:
- Only trust the
token
for authorization (viajwt.sub
). - Treat
userData
as a convenience snapshot for rendering.
3) Hooking into every request
We use SvelteKit's handle
hook to verify JWTs and gate routes.
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { verifyJwtToken, getUserDataFromCookie } from '$lib/server/jwt';
const protectedPrefixes = ['/app'];
export const handle: Handle = async ({ event, resolve }) => {
const jwtPayload = await verifyJwtToken(event.cookies.get('token'));
const userData = getUserDataFromCookie(event.cookies);
event.locals.jwt = jwtPayload;
event.locals.userData = userData;
const isProtected = protectedPrefixes.some((p) => event.url.pathname.startsWith(p));
if (isProtected && !event.locals?.jwt?.sub) {
return Response.redirect(new URL('/signin', event.url), 303);
}
if ((event.url.pathname === '/signin' || event.url.pathname === '/signup') && event.locals?.jwt?.sub) {
return Response.redirect(new URL('/app', event.url), 303);
}
return resolve(event);
};
Highlights:
- Unauthenticated users are redirected to
/signin
when accessing/app
. - Signed-in users get redirected away from
/signin
to/app
. locals.jwt
andlocals.userData
are available in load functions.
4) Accessing the signed-in user in load functions
The root layout exposes locals.userData
to the client so components can render the signed-in state.
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return { userData: locals.userData ?? null };
};
5) Logging in
For simplicity, this example uses a fake verifyUser
helper. Replace it with your real DB check.
// src/lib/server/verifyUser.ts
export async function verifyUser(identifier: string, password: string) {
if (!identifier || !password) return null;
return { id: 'user_1', email: identifier };
}
// src/routes/signin/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { setJwt, setUserDataCookie } from '$lib/server/jwt';
import { verifyUser } from '$lib/server/verifyUser';
export const load: PageServerLoad = async ({ locals }) => {
if (locals?.jwt?.sub) throw redirect(303, '/app');
return {};
};
export const actions: Actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const identifier = String(data.get('identifier') || '');
const password = String(data.get('password') || '');
const user = await verifyUser(identifier, password);
if (!user) return fail(400, { error: 'Invalid credentials' });
await setJwt(cookies, { sub: user.id });
setUserDataCookie(cookies, { id: user.id, email: user.email });
throw redirect(303, '/app');
}
};
Explanation:
- User credentials are checked.
- If valid, we set both
token
anduserData
cookies. - Redirect to
/app
.
6) Logging out
Clearing both cookies fully signs the user out.
// src/routes/logout/+server.ts
import type { RequestHandler } from './$types';
import { clearAuthCookies } from '$lib/server/jwt';
import { redirect } from '@sveltejs/kit';
export const POST: RequestHandler = async ({ cookies }) => {
clearAuthCookies(cookies);
throw redirect(303, '/signin');
};
7) Protecting pages and endpoints
You can guard endpoints directly by checking locals.jwt
.
// inside a +server.ts
if (!event.locals?.jwt?.sub) {
throw error(401, 'Unauthorized');
}
Or retrieve the safe user snapshot:
const userData = event.locals.userData;
8) Putting it all together
End-to-end flow:
- User signs in with your preferred authentication method.
- You issue a JWT (
token
cookie) and a UI-friendlyuserData
cookie. - On each request, the
handle
hook verifies the token and populateslocals
. - Protected routes redirect unauthenticated users; authenticated users land in
/app
. - Client components render from
userData
; server actions authorize withjwt.sub
.
This setup gives you stateless sessions with the ergonomics of traditional session auth, while staying simple to scale in SvelteKit.
👉 GitHub Project Repo
👉 Demo Site
Thank You!
Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.
If you want to support me, please follow me on Spotify or SoundCloud!
Please also feel free to check out my Portfolio Site
Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.
Comments (1)
Join the discussion and share your thoughts on this post.