@chaeco/jwt-permission
v1.1.2
Published
Framework-agnostic JWT permission middleware for Node.js (Hoa, Koa, Express, etc.)
Maintainers
Readme
@chaeco/jwt-permission
English | 中文
Framework-agnostic JWT route permission middleware for Node.js. Compatible with Hoa, Koa, Express, and any framework that uses a ctx.state.user convention.
Features
- ✅ Framework-agnostic — works with Hoa, Koa, Express, and more
- ✅ Route-based permission control
- ✅ Auto-discovery via
@chaeco/auto-router— no manual route lists needed - ✅ Public and protected route support
- ✅ Path parameter matching (e.g.
/api/users/:userId) - ✅ URL-decode safety — prevents
%xxencoding bypass attacks - ✅ Custom route matching logic
- ✅ Custom unauthorized response handler
- ✅ Full TypeScript generics support
Installation
npm install @chaeco/jwt-permissionOr pin to a specific version:
npm install @chaeco/[email protected]Quick Start
Option 1: Auto-discovery (Recommended)
Use with @chaeco/auto-router to automatically read route permission metadata:
import { jwtAuth } from '@chaeco/jwt-permission'
import { autoRouter } from '@chaeco/auto-router'
// autoRouter scans controllers/ and extracts permission metadata
app.extend(
autoRouter({
defaultRequiresAuth: false,
})
)
// jwtAuth reads permission config automatically from autoRouter
app.use(
jwtAuth({
autoDiscovery: true,
})
)Option 2: Manual configuration
import { jwtAuth } from '@chaeco/jwt-permission'
app.use(
jwtAuth({
publicRoutes: [
{ method: 'POST', path: '/api/auth/login' },
{ method: 'POST', path: '/api/users/register' },
],
protectedRoutes: [
{ method: 'GET', path: '/api/users/info' },
{ method: 'DELETE', path: '/api/users/:id' },
],
})
)Using in a controller
import { getCurrentUser } from '@chaeco/jwt-permission'
async function getInfoHandler(ctx) {
const user = getCurrentUser(ctx)
ctx.res.body = { success: true, data: user }
}API
jwtAuth(options)
Creates a JWT permission middleware (shorthand alias for createJwtPermission).
Options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| autoDiscovery | boolean | true | Enable auto-discovery from app.$routes |
| publicRoutes | RouteRule[] | — | Routes that skip JWT verification |
| protectedRoutes | RouteRule[] | — | Routes that require JWT verification |
| unauthorizedResponse | (ctx) => void | built-in | Custom 401 response handler |
| isPublicRoute | (method, path) => boolean | — | Custom public route matcher (overrides built-in) |
| isProtectedRoute | (method, path) => boolean | — | Custom protected route matcher (overrides built-in) |
| defaultDeny | boolean | false | Reject unknown routes with 401 (recommended for production) |
| onUnauthorized | (ctx, reason) => void | — | Callback when request is rejected: 'no_user' or 'default_deny' |
Returns: PermissionMiddleware<TContext>
Built-in
unauthorizedResponseauto-detects the framework style:
- Hoa style: writes to
ctx.res.status/ctx.res.body- Koa style: writes to
ctx.status/ctx.body- Other frameworks (e.g. Express): must provide a custom
unauthorizedResponse
createJwtPermission<TContext>(options)
Same as jwtAuth(), with explicit generic support for custom context types:
import { createJwtPermission } from '@chaeco/jwt-permission'
import type { MyAppContext } from './types'
const middleware = createJwtPermission<MyAppContext>({ ... })getCurrentUser(ctx)
Returns the authenticated user stored in ctx.state.user, or null if not authenticated.
const user = getCurrentUser(ctx)
// { id: 1, username: 'alice', ... } | nullisAuthenticated(ctx)
Returns true if ctx.state.user is present.
if (isAuthenticated(ctx)) {
// request is authenticated
}Integration with @chaeco/auto-router
Marking permissions in controllers
// controllers/users/get-info.ts
import { createHandler } from '@chaeco/auto-router'
export default createHandler(
async ctx => {
ctx.res.body = { success: true, data: ctx.state.user }
},
{ requiresAuth: true },
)
// controllers/auth/post-login.ts
export default createHandler(
async ctx => {
// login logic
},
{ requiresAuth: false },
)How it works
autoRouterscans thecontrollers/directory- Extracts
requiresAuthmetadata fromcreateHandler()calls - Stores route info in
app.$routes jwtAuthreads and cachesapp.$routeson the first request- No duplicated route lists needed!
Route Rules
HTTP methods
GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS — case-insensitive.
Path format
- Must start with
/ - Trailing slash is significant (
/api/users≠/api/users/) - Supports
:paramNamesyntax for dynamic segments - Wildcards (
*) are not supported — useisPublicRoute/isProtectedRouteinstead
Path parameter matching
{ method: 'GET', path: '/api/users/:userId/posts/:postId' }
// matches: /api/users/123/posts/456Custom Route Matching
import { createJwtPermission } from '@chaeco/jwt-permission'
const middleware = createJwtPermission({
isPublicRoute: (method, path) => path.startsWith('/api/public'),
isProtectedRoute: (method, path) => path.startsWith('/api/admin'),
})
methodis always uppercase (e.g.'GET').pathis URL-decoded and query-string-free.
Custom Unauthorized Response
import { jwtAuth } from '@chaeco/jwt-permission'
const middleware = jwtAuth({
unauthorizedResponse: ctx => {
ctx.res.status = 401
ctx.res.body = {
success: false,
message: 'Authentication required',
code: 'UNAUTHORIZED',
}
},
})Middleware Order
JWT token parsing must run before jwtAuth. The upstream JWT middleware is responsible for verifying the token and writing the decoded payload to ctx.state.user.
// Hoa
import { jwt } from '@hoajs/jwt'
app.use(jwt({ secret: process.env.JWT_SECRET!, algorithms: ['HS256'] }))
app.use(jwtAuth({ autoDiscovery: true }))
// Koa
import koaJwt from 'koa-jwt'
app.use(koaJwt({ secret: process.env.JWT_SECRET! }))
app.use(jwtAuth({ autoDiscovery: true }))Request Flow
Incoming request
↓
[1] JWT parsing middleware
├─ Verify token signature & expiry
├─ Valid → write decoded payload to ctx.state.user
└─ Invalid → return 401
↓
[2] autoRouter (route registration)
├─ Scan controllers/
├─ Collect requiresAuth metadata
└─ Store in app.$routes
↓
[3] jwtAuth (permission check)
├─ Public route? → pass through
├─ Protected route?
│ ├─ ctx.state.user exists → pass through
│ └─ ctx.state.user absent → return 401
└─ Unknown route
├─ defaultDeny: true → return 401
└─ defaultDeny: false → pass through (default)
↓
Route handler (business logic)Best Practices
✅ Do:
- Use
autoDiscovery: truetogether with@chaeco/auto-router - Explicitly mark route permissions with
createHandler()in controllers - Use
getCurrentUser()in handlers after the middleware chain - Regularly audit route permission configuration
- Use strong JWT secrets
❌ Don't:
- Forget to place JWT token-parsing middleware before
jwtAuth - Use wildcards (
*) in route rules — useisPublicRoute/isProtectedRouteinstead - Hardcode permissions for sensitive endpoints — declare them with
createHandlerin controllers
Examples
Hoa + autoRouter (recommended)
import { Hoa } from 'hoa'
import { jwt } from '@hoajs/jwt'
import { jwtAuth, getCurrentUser } from '@chaeco/jwt-permission'
import { autoRouter } from '@chaeco/auto-router'
const app = new Hoa()
app.use(jwt({ secret: process.env.JWT_SECRET!, algorithms: ['HS256'] }))
app.use(jwtAuth({ autoDiscovery: true }))
app.extend(autoRouter({ defaultRequiresAuth: false }))
app.get('/api/users/info', async ctx => {
ctx.res.body = { success: true, data: getCurrentUser(ctx) }
})
app.listen(3000)Koa
import Koa from 'koa'
import koaJwt from 'koa-jwt'
import { jwtAuth } from '@chaeco/jwt-permission'
const app = new Koa()
app.use(koaJwt({ secret: process.env.JWT_SECRET! }))
app.use(
jwtAuth({
publicRoutes: [{ method: 'POST', path: '/api/auth/login' }],
protectedRoutes: [{ method: 'GET', path: '/api/profile' }],
})
)
app.listen(3000)Express
Express uses a different req/res structure. Bridge req.auth to ctx.state.user and provide a custom unauthorizedResponse:
import express from 'express'
import { expressjwt } from 'express-jwt'
import { jwtAuth } from '@chaeco/jwt-permission'
const app = express()
app.use((req, res, next) => {
expressjwt({ secret: process.env.JWT_SECRET!, algorithms: ['HS256'] })(req, res, () => {
;(req as any).state = { user: (req as any).auth }
next()
})
})
const permission = jwtAuth({
publicRoutes: [{ method: 'POST', path: '/api/auth/login' }],
protectedRoutes: [{ method: 'GET', path: '/api/profile' }],
unauthorizedResponse: ctx => {
;(ctx as any).res.status(401).json({ success: false, code: 'UNAUTHORIZED' })
},
})
app.use((req, res, next) => {
permission({ req, res, state: (req as any).state ?? {} } as any, next as any)
})
app.listen(3000)TypeScript
All types are exported directly from the package:
import type {
HttpMethod,
PermissionContext,
PermissionMiddleware,
RouteRule,
JwtPermissionOptions,
} from '@chaeco/jwt-permission'FAQ
Q: Can I mix auto-discovery and manual configuration?
A: Yes. autoDiscovery only fills in the side that is not manually provided (publicRoutes or protectedRoutes). Both sides are independent.
Q: How do I make a route always public?
jwtAuth({
publicRoutes: [
{ method: 'POST', path: '/api/auth/login' },
{ method: 'POST', path: '/api/users/register' },
{ method: 'GET', path: '/api/health' },
],
})Q: How do I handle token refresh?
Mark the refresh endpoint as a public route:
{ method: 'POST', path: '/api/auth/refresh' }Q: What happens to routes not in either list?
They are allowed through by default (pass-through behavior). To reject unknown routes in production, use defaultDeny: true:
jwtAuth({
defaultDeny: true,
// unknown routes → 401
})Performance
- ✅ Route regexes are compiled once and cached at module level
- ✅ Auto-discovered routes are read and cached on the first request only
- ✅ When both sides are covered by custom functions, all route list parsing is skipped
- ✅ Token verification is handled by the upstream JWT middleware — this middleware only checks whether
ctx.state.userexists
AI Tool Skills
This package includes AI agent skills for Claude Code and OpenAI Codex.
After installation, run one command to copy the skills into your project:
npx jwt-permission-init-skillsThis places skill files into .claude/skills/jwt-permission/ and .codex/skills/jwt-permission/.
AI tools will then enforce correct middleware order, framework-specific setup (Hoa / Koa / Express),
route rule format, and auth best practices when adding JWT permission checks.
Changelog
See CHANGELOG.md.
License
MIT
