Skip to main content

JWT Authentication

@zeltjs/auth-jwt provides stateless JWT-based authentication for SPAs, mobile apps, and APIs.

Installation

pnpm add @zeltjs/auth-jwt

Quick Start

1. Set the Secret

Set the JWT_SECRET environment variable:

# .env
JWT_SECRET=your-secret-key-at-least-32-characters

2. Register Middleware

import { createApp } from '@zeltjs/core';
import { JwtMiddleware, JwtConfig } from '@zeltjs/auth-jwt';

const app = createApp({
http: {
controllers: [AuthController, UserController],
middlewares: [JwtMiddleware],
},
configs: [JwtConfig],
});

3. Generate Tokens

Use JwtService to sign tokens at login:

import { Controller, Post, bodyParam, inject } from '@zeltjs/core';
import { JwtService } from '@zeltjs/auth-jwt';
import * as v from 'valibot';

const LoginSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.string(),
});

@Controller('/auth')
class AuthController {
constructor(private jwtService = inject(JwtService)) {}

@Post('/login')
async login(body = bodyParam(LoginSchema)) {
const user = await validateCredentials(body.email, body.password);
if (!user) {
throw new HTTPException(401, { message: 'Invalid credentials' });
}

const token = await this.jwtService.sign({
sub: user.id,
roles: user.roles,
});

return { token };
}
}

4. Protect Routes

Use @Authorized() to require authentication:

import { Controller, Get, Authorized, currentUser } from '@zeltjs/core';

@Controller('/users')
class UserController {
@Authorized()
@Get('/me')
me(user = currentUser()) {
return user;
}
}

JwtService API

MethodDescription
sign(payload)Create a signed JWT token
verify(token)Verify and decode a token (throws on invalid)
decode(token)Decode without verification (returns null on error)

Sign

Create a signed token with custom payload:

const token = await jwtService.sign({
sub: user.id,
roles: ['admin', 'user'],
customClaim: 'value',
});

Verify

Verify a token and get its payload (throws if invalid or expired):

try {
const payload = await jwtService.verify(token);
console.log(payload.sub); // user ID
} catch (error) {
// Token is invalid or expired
}

Decode

Decode without verification (useful for reading expired tokens):

const payload = jwtService.decode(token);
if (payload) {
console.log(payload.sub);
}

Configuration

Extend JwtConfig to customize behavior:

import { JwtConfig, type JwtPayload, type ResolveUserResult } from '@zeltjs/auth-jwt';
import { Config } from '@zeltjs/core';

@Config
class CustomJwtConfig extends JwtConfig {
override get secret(): string {
return process.env.JWT_SECRET!;
}

override get expiresIn(): string {
return '7d'; // Token expiration (default: '1h')
}

override get resolveUser(): (payload: JwtPayload) => Promise<ResolveUserResult> {
return async (payload) => {
const user = await db.users.findById(payload.sub);
return {
user: { id: user.id, name: user.name, email: user.email },
roles: user.roles,
};
};
}
}

Register your custom config:

const app = createApp({
http: {
controllers: [AuthController, UserController],
middlewares: [JwtMiddleware],
},
configs: [CustomJwtConfig], // Your config replaces JwtConfig
});

Configuration Options

OptionTypeDefaultDescription
secretstringprocess.env.JWT_SECRETSecret key for signing
expiresInstring'1h'Token expiration (e.g., '15m', '7d')
resolveUserfunctionReturns { user: sub, roles: [] }Resolves user from JWT payload

Client Integration

Sending the Token

Clients should include the token in the Authorization header:

fetch('/api/users/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
});

Token Storage

Store tokens securely on the client:

PlatformRecommended Storage
Browser SPAhttpOnly cookie or memory (avoid localStorage)
Mobile AppSecure storage (Keychain / Keystore)
Server-to-ServerEnvironment variable

Token Refresh Pattern

For long-lived sessions, implement a refresh token flow:

@Controller('/auth')
class AuthController {
constructor(private jwtService = inject(JwtService)) {}

@Post('/refresh')
async refresh(body = bodyParam(RefreshSchema)) {
const payload = await this.jwtService.verify(body.refreshToken);

const user = await db.users.findById(payload.sub);
const accessToken = await this.jwtService.sign({
sub: user.id,
roles: user.roles,
});

return { accessToken };
}
}

Error Responses

StatusCodeWhen
401UNAUTHORIZEDNo token, invalid token, or expired token
403FORBIDDENValid token but missing required role
{
"code": "UNAUTHORIZED",
"message": "Authentication required"
}

Edge Runtime Support

@zeltjs/auth-jwt uses the jose library which supports Web Crypto API, making it compatible with:

  • Cloudflare Workers
  • Vercel Edge Functions
  • Deno Deploy
  • Node.js