メインコンテンツまでスキップ

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

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:

  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

OptionTypeDefaultDescription
secretstringenv.getRequired('JWT_SECRET')Secret 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),
    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

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