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
const app = createApp([http({
controllers: [AuthController, UserController],
middlewares: [JwtMiddleware],
})], { configs: [JwtConfig] });
3. Generate Tokens
Use JwtService to sign tokens at login:
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 = validated(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:
@Controller('/users')
class UserController {
@Authorized()
@Get('/me')
me(user = currentUser()) {
return user;
}
}
JwtService API
| Method | Description |
|---|---|
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:
async createToken(userId: string) {
return this.jwtService.sign({
sub: userId,
roles: ['admin', 'user'],
customClaim: 'value',
});
}
}
Verify
Verify a token and get its payload (throws if invalid or expired):
async validateToken(token: string) {
try {
const payload = await this.jwtService.verify(token);
console.log(payload.sub);
return payload;
} catch {
return null;
}
}
}
Decode
Decode without verification (useful for reading expired tokens):
readToken(token: string) {
const payload = this.jwtService.decode(token);
if (payload) {
console.log(payload.sub);
}
return payload;
}
}
Configuration
Extend JwtConfig to customize behavior:
@Config
class CustomJwtConfig extends JwtConfig {
constructor(private userRepo = inject(UserRepository)) {
super();
}
override get secret(): string {
return this.env.getRequired('JWT_SECRET');
}
override get expiresIn(): string {
return '7d';
}
override get resolveUser(): (payload: JwtPayload) => Promise<ResolveUserResult> {
return async (payload) => {
const user = await this.userRepo.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] });
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
secret | string | env.getRequired('JWT_SECRET') | Secret key for signing |
expiresIn | string | '1h' | Token expiration (e.g., '15m', '7d') |
resolveUser | function | Returns { 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:
| Platform | Recommended Storage |
|---|---|
| Browser SPA | httpOnly cookie or memory (avoid localStorage) |
| Mobile App | Secure storage (Keychain / Keystore) |
| Server-to-Server | Environment variable |
Token Refresh Pattern
For long-lived sessions, implement a refresh token flow:
@Controller('/auth')
class AuthController {
constructor(
private jwtService = inject(JwtService),
private userRepo = inject(UserRepository)
) {}
@Post('/refresh')
async refresh(body = validated(RefreshSchema)) {
const payload = await this.jwtService.verify(body.refreshToken);
const user = await this.userRepo.findById(payload.sub!);
const accessToken = await this.jwtService.sign({
sub: user.id,
roles: user.roles,
});
return { accessToken };
}
}
Error Responses
| Status | Code | When |
|---|---|---|
| 401 | UNAUTHORIZED | No token, invalid token, or expired token |
| 403 | FORBIDDEN | Valid 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