Full-Stack Development

A Complete Guide to JWT Authentication in SvelteKit

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

JS
Jay Simons
Author
A Complete Guide to JWT Authentication in SvelteKit

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 named token
  • 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.

TS
// 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 called token.
  • 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.

TS
// 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 (via jwt.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.

TS
// 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 and locals.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.

TS
// 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.

TS
// src/lib/server/verifyUser.ts
export async function verifyUser(identifier: string, password: string) {
  if (!identifier || !password) return null;
  return { id: 'user_1', email: identifier };
}
TS
// 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 and userData cookies.
  • Redirect to /app.

6) Logging out

Clearing both cookies fully signs the user out.

TS
// 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.

TS
// inside a +server.ts
if (!event.locals?.jwt?.sub) {
  throw error(401, 'Unauthorized');
}

Or retrieve the safe user snapshot:

TS
const userData = event.locals.userData;

8) Putting it all together

End-to-end flow:

  1. User signs in with your preferred authentication method.
  2. You issue a JWT (token cookie) and a UI-friendly userData cookie.
  3. On each request, the handle hook verifies the token and populates locals.
  4. Protected routes redirect unauthenticated users; authenticated users land in /app.
  5. Client components render from userData; server actions authorize with jwt.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.

Jay

Jay

Please leave a comment! :)

© 2025 Jay Simons. All rights reserved.