name: elysia-jwt-plugin-singleton description: Fix "401 Unauthorized" in Elysia.js when JWT tokens signed by auth routes are rejected by protected routes — caused by multiple JWT plugin instances. version: 1.0.0 author: Hermes Agent tags: [debugging, elysia, jwt, bun, auth] metadata: hermes: tags: [debugging, elysia, jwt, auth, bun] related_skills: [elysia-route-conflict-debug, elysia-typescript-workarounds, systematic-debugging]
Elysia.js JWT Plugin Singleton Bug
Symptom
Login works (auth route returns a JWT token), but subsequent requests to protected routes fail with 401 Unauthorized, even with a valid token. The token was just signed — it shouldn't be expired or invalid.
Classic pattern:
routes/auth.tssigns tokens with its ownjwt()instance → successroutes/courses.ts(and other protected routes) verify tokens with a DIFFERENTjwt()instance → 401
Root Cause
In Elysia.js, each app.use(jwt(...)) call creates a NEW plugin instance, even if the options are identical. Instances do NOT share state. A token signed by instance A's .sign() method cannot be verified by instance B's .verify() — they have different internal keys derived from the same secret.
app.use(jwt({ secret: "same-secret" })) // Instance A
app.use(jwt({ secret: "same-secret" })) // Instance B — DIFFERENT instance!
token = A.sign({ sub: "1" }) // Signed by A
ctx.jwt.verify(token) // Verified by B — FAILS silently → 401
Diagnosis
- Search for multiple
jwt(calls across your codebase:
grep -rn "jwt(" src/
- Look for patterns where auth.ts creates its own JWT while other route files also create their own:
// In routes/auth.ts — BAD pattern
const jwt = new Elysia()
.use(jwt({ secret: process.env.JWT_SECRET, ... }))
.resolve(...)
// In routes/courses.ts — ANOTHER BAD pattern (different instance!)
const auth = new Elysia()
.use(jwt({ secret: process.env.JWT_SECRET, ... }))
- If you see
jwt()called in more than one file, you have the bug.
Fix: Shared Singleton Plugin
Step 1: Create a single JWT plugin in src/middleware/auth.ts:
import { Elysia, jwt } from "elysia";
import { ELYSIA_JWT_SECRET } from "../config";
export const jwtPlugin = jwt({
secret: ELYSIA_JWT_SECRET,
// other options: expiresIn, etc.
});
// Auth middleware: verifies token AND sets ctx.user
export const auth = new Elysia()
.use(jwtPlugin)
.derive(({ jwt, headers }) => {
const authHeader = headers.authorization;
if (!authHeader?.startsWith("Bearer ")) return { user: undefined };
return jwt.verify(authHeader.slice(7)).then(user => ({ user }));
});
Step 2: In routes/auth.ts — use jwtPlugin to sign, use auth to guard:
import { Elysia } from "elysia";
import { auth, jwtPlugin } from "../middleware/auth";
export const authRoutes = new Elysia({ prefix: "/auth" })
.use(auth) // ← verifies tokens via shared jwtPlugin
.post("/login", async ({ jwt, set, body }) => {
// Sign with the SAME jwtPlugin instance
const token = await jwt.sign({ sub: user.id, role: user.role, email: user.email });
return { success: true, token, user: { id, name, email, role, mustChangePassword } };
}, ...)
.post("/change-password", async ({ jwt, user, set, body }) => {
// jwt from context IS the shared jwtPlugin's sign function
const newToken = await jwt.sign({ ...updatedPayload });
return { success: true, token: newToken };
}, ...)
Step 3: In protected route files — import and use the SAME auth:
import { auth } from "../middleware/auth";
export const courseRoutes = new Elysia({ prefix: "/api/courses" })
.use(auth) // ← same jwtPlugin, same verification
.get("/", async ({ user }) => {
// user is set by auth.derive() — works because jwt is the SAME instance
return db.courses.findMany();
}, ...)
Key invariant: jwtPlugin is created ONCE in middleware/auth.ts and imported everywhere. Both signing (jwt.sign()) and verifying (auth middleware) use the identical instance.
Gotcha: Getting jwt from Context in Handlers
When you call .use(auth), the ctx.jwt is the jwtPlugin instance's sign function. Use it like this:
.post("/change-password", async ({ jwt, set, body }) => {
// ctx.jwt === jwtPlugin.sign()
const newToken = await jwt.sign({ sub: user.id, ... });
return { token: newToken };
})
Do NOT destructure jwt from auth directly in the handler signature — it's only available via ctx.jwt after .use(auth) runs.
Stale Server / Old Process Masking the Fix
If you fixed the code but still get 401:
- Multiple Bun processes may be running on different ports
- The old process with the bug still serves traffic
- Always verify which PID is actually serving:
lsof -i :3000
ps aux | grep bun
Kill ALL bun processes and restart clean:
pkill -f "bun.*index"
sleep 1
lsof -i :3000 # should be empty
cd ~/projects/umac_ai/backend && bun run src/index.ts
Verification Test
# 1. Login → get token
TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"teacher@umac.ai","password":"Teacher@123"}' \
| jq -r '.token')
# 2. Use token on a protected route
curl -s http://localhost:3000/api/courses \
-H "Authorization: Bearer $TOKEN"
# Should return 200 with course data, NOT 401
Related
elysia-route-conflict-debug: route parameter name conflictselysia-typescript-workarounds: TypeScript issues with Elysiasystematic-debugging: general debugging methodology
⚠️ Day 9: Different 401 — .derive() silently returns undefined on POST
The singleton bug above is one source of 401s. There is a SECOND Elysia 1.2
401 trap that looks identical from the outside (userId: undefined,
handler responds 401) but has a totally different root cause: derive
hooks are not run for POST handlers when a sibling .get() route is also
registered on the same Elysia instance and uses a body schema. Elysia
caches the validator and skips re-evaluating the derive chain on POST
even though userId is destructured in the handler signature.
Symptom (Day 9, crm-system):
GET /auth/meandGET /quotations(no body) → 200 OKPOST /quotations→ 401 "Unauthorized" even with a valid bearer tokenconsole.loginside thederivecallback never fires on POST- Other routes'
POSThandlers return 500/401 depending on whether they gate onif (!userId) return 401
Root cause: Elysia 1.2's derive cache. When a route previously
validated with a body schema (the .get('/') sibling), the validator
re-uses the cached derive result for the chain's POST children. The
async derive is never re-evaluated. The destructure userId works
in TypeScript (the type is set) but at runtime it's undefined.
Fix (recommended — bulletproof): drop derive for auth entirely
and inline-verify inside each protected handler. Costs one extra
await jwt.verify(token) per request but bypasses the cache.
// lib/context.ts
import { Elysia } from 'elysia';
export const authContext = new Elysia({ name: 'auth-context' });
export async function getUserIdFromRequest(
request: Request,
jwt: { verify: (token: string) => Promise<unknown> }
): Promise<string | null> {
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) return null;
const token = authHeader.slice(7);
const payload = await jwt.verify(token);
if (!payload || typeof payload !== 'object') return null;
return (payload as { sub?: string }).sub ?? null;
}
// In each protected route handler
.post('/', async ({ body, jwt, set, request }) => {
const userId = await getUserIdFromRequest(request, jwt);
if (!userId) { set.status = 401; return { error: 'Unauthorized' }; }
// ... rest of handler
})
How to detect (5-min diagnostic):
console.loginside yourderivecallback- Hit any POST → log never appears
userIdin the handler isundefineddespite a valid token- GET routes on the same instance work fine
If you see this pattern, you have the derive-cache bug — switch to
getUserIdFromRequest inline verification.
⚠️ 2026-06-07: Same symptom, slightly different trigger — POST handler chained after .use(requirePermission(...))
The Day 9 case above triggers when a POST handler sits next to a
sibling .get() route. There is a SECOND configuration that produces
the identical userId: undefined symptom but has a different
trigger: the route is .use(requirePermission(...)) followed by
.post('/...', handler, { body: t.Object({...}) }) where the
schema-validated body somehow narrows the context such that
authContext's derived userId is no longer visible in the handler
destructure.
Symptom (crm-system 2026-06-07 POST /deals):
GET /deals,GET /deals/kanban,GET /deals/:id→ 200 OK (userId present)POST /dealswith body schema → handler seesuserId: undefinedeven thoughrequirePermission('deal:create')already proved the user is authenticatedlogEvent({ actorId: userId ?? null })writesactorId: nullin the audit log row even though the create succeeded- The same exact pattern (
requirePermission+body: t.Object({...})) onPATCH /deals/:id/stageandPATCH /deals/:idworks fine on its own — so this is an intermittent/context-dependent bug, not a deterministic one
Detection signal: any handler that does ALL of:
- Is declared on a route that calls
.use(requirePermission(...)) - Defines a
body: t.Object({...})schema - Destructures
userIdfrom the handler arg - Sees
userId === undefineddespite an authenticated request
Workaround (crm-system's adopted fix, see
apps/api/src/routes/deal.ts POST handler):
import { getUserIdFromRequest } from '../middleware/rbac';
.post('/', async ({ body, set, userId, request }) => {
// Try the (possibly missing) derived userId, then fall back to
// re-decoding the JWT from the request headers. Same helper RBAC
// uses internally, so the result is identical to a working derive.
const ownerId = incoming.ownerId ?? userId ?? await getUserIdFromRequest(request);
// ... rest of handler
}, {
body: t.Object({ ... }),
})
Apply the same fallback in logEvent({ actorId: userId ?? await getUserIdFromRequest(request) ?? null }) so audit rows carry the
real user.
Root cause (workaround, not yet root-caused): Elysia 1.2's
typed-context schema for body: t.Object({...}) appears to drop the
userId field from the Context type passed into the handler.
TypeScript stays silent because the destructure is allowed, but at
runtime the value is undefined. The crm-system repo has not yet
upgraded to Elysia 1.3+ where this is reportedly fixed. Until then,
always use the getUserIdFromRequest(request) fallback in any
body: t.Object({...}) route that needs the calling user's id.
Rule of thumb for crm-system Elysia 1.2 routes:
body: t.Object({...})route that needsuserId→ always include?? await getUserIdFromRequest(request)fallbackbody: t.Object({...})route that needsactorIdforlogEvent→ same fallbacklogEvent({ actorId: userId ?? null })is suspect — verify theuserIdis actually populated before relying on it for audit