How to Setup Middleware for Elysia.js
I was working on vidiopintar.com, a video analysis API built with Elysia. The API had endpoints for YouTube transcription, summarization, and channel data.
Problem: I deployed it with zero authentication. Anyone could hit any endpoint. I didn't want people using my API without permission.
I needed to add API key auth fast, but Elysia's middleware system is... different. Here's how I figured it out after 6 hours of debugging.
What I Needed
Simple requirements:
- Check for
X-API-Key
header - Validate against SQLite database
- Return 401 if invalid
- Only protect YouTube endpoints (not docs)
Coming from Express, I thought "just add middleware before the routes". Nope. Elysia doesn't work like that.
Attempt #1: Plugin Approach (Failed)
First try - make it a plugin like Express middleware:
// ❌ This approach didn't work
export const createAuthMiddleware = (dbService: DatabaseService) => {
return new Elysia({ name: "auth" })
.onBeforeHandle(async ({ headers, set, request }) => {
const apiKeyHeader = headers["x-api-key"];
if (!apiKeyHeader) {
set.status = 401;
return { error: "X-API-Key header is required" };
}
// Validation logic...
});
};
// Applied like this:
export const youtubeController = (dbService: DatabaseService) => {
return new Elysia({ name: "youtube" })
.use(createAuthMiddleware(dbService)) // ❌ Didn't work
.get("/youtube/trending/videos", async () => {
// Route handler
});
};
The middleware got created (I could see it in logs), but onBeforeHandle
never fired. Requests just went straight to my routes.
Attempt #2: Try Different Hooks (Still Failed)
Maybe onBeforeHandle
was wrong? Tried all the lifecycle hooks:
// ❌ Tried multiple hooks
export const createAuthMiddleware = (dbService: DatabaseService) => {
return new Elysia({ name: "auth" })
.onRequest(async ({ request }) => {
console.log("🚨 onRequest triggered"); // ✅ This worked
})
.onTransform(async ({ request }) => {
console.log("🔄 onTransform triggered"); // ❌ Never called
})
.onBeforeHandle(async ({ headers, set }) => {
console.log("🔍 onBeforeHandle triggered"); // ❌ Never called
// Auth logic here
})
.onAfterHandle(async ({ request }) => {
console.log("✅ onAfterHandle triggered"); // ❌ Never called
});
};
The Debug Session:
🎥 Creating YouTube controller...
📦 Applying auth middleware to YouTube routes...
🔧 Creating auth middleware...
🦊 Server is running at localhost:3000
# When making request:
🎬 YOUTUBE CONTROLLER - onRequest triggered
📍 Request URL: http://localhost:3000/youtube/trending/videos
🚨 onRequest - MIDDLEWARE ENTRY POINT ✅ Called
📍 Request URL: http://localhost:3000/youtube/trending/videos
📦 GROUP - onRequest triggered AFTER middleware
📍 Request URL: http://localhost:3000/youtube/trending/videos
# ❌ But onBeforeHandle was NEVER called!
Only onRequest
fired. The other hooks just... didn't exist apparently.
Attempt #3: Registration Order (Still Failed)
Read the docs. Found out registration order matters - hooks only apply to routes added after them.
// ❌ Tried moving middleware before group
export const youtubeController = (dbService: DatabaseService) => {
return new Elysia({ name: "youtube" })
.use(createAuthMiddleware(dbService)) // Apply before group
.group("", (app) => {
return app
.get("/youtube/trending/videos", async () => {
// Routes here should inherit middleware
});
});
};
Same issue. onRequest
worked, everything else ignored.
The Breakthrough
After hours of Stack Overflow and GitHub issues, found the problem: Elysia plugins have local scope. onBeforeHandle
doesn't propagate like Express middleware.
The solution: use guard
with beforeHandle
.
What Actually Works
Step 1: Return a Function, Not a Plugin
// Return a function, not an Elysia instance
export const createAuthMiddleware = (dbService: DatabaseService) => {
return async ({ headers, set }: any) => {
const apiKeyHeader = headers["x-api-key"];
if (!apiKeyHeader) {
set.status = 401;
return { error: AuthError.MISSING_API_KEY };
}
const apiKey = await dbService.apiKey.getApiKeyByToken(apiKeyHeader);
if (!apiKey) {
set.status = 401;
return { error: AuthError.INVALID_API_KEY };
}
if (dbService.apiKey.isExpired(apiKey)) {
set.status = 401;
return { error: AuthError.EXPIRED_API_KEY };
}
// Authentication passed, continue to route handler
};
};
Step 2: Use Guard Pattern
// guard + beforeHandle = success
export const youtubeController = (dbService: DatabaseService) => {
const authMiddleware = createAuthMiddleware(dbService);
return new Elysia({ name: "youtube" })
.guard({
beforeHandle: authMiddleware // ✅ This works!
}, (app) => {
return app
.get("/youtube/search", async ({ query }) => {
// Protected route
})
.get("/youtube/trending/videos", async () => {
// Protected route
})
// All routes in this guard are protected
});
};
Step 3: The Database Stuff
API Key Model (src/database/model/apikey.ts
):
export class ApiKey extends BaseModel {
async getApiKeyByToken(token: string): Promise<ApiKeySchema | null> {
const result = await this.db.query("SELECT * FROM api_keys WHERE token=$token;").get({ $token: token });
return result ? result as ApiKeySchema : null;
}
isExpired(apiKey: ApiKeySchema): boolean {
return new Date(apiKey.expired_at) < new Date();
}
}
Error Types (src/types/auth.types.ts
):
export enum AuthError {
MISSING_API_KEY = "X-API-Key header is required",
INVALID_API_KEY = "Invalid API key",
EXPIRED_API_KEY = "API key has expired",
}
API Key Generation Script (scripts/create-apikey.ts
):
import { DatabaseService } from "../src/database/database";
import { generateApiKey, createExpirationDate } from "../src/lib/utils";
async function createApiKey(scope: string = "youtube", days: number = 365) {
const dbService = new DatabaseService();
await dbService.runMigration();
const apiKey = generateApiKey(32);
const expiresAt = createExpirationDate(days);
await dbService.apiKey.createApiKey(apiKey, scope, expiresAt);
console.log(`✅ API Key created: ${apiKey}`);
}
What I Learned
Elysia ≠ Express: Don't assume middleware works the same way. Elysia routes only accept one handler.
Use Guards: For protecting multiple routes, guard
+ beforeHandle
is the pattern:
.guard({
beforeHandle: authFunction
}, (app) => {
// protected routes here
});
Order Matters: Hooks only apply to routes registered after them:
// Wrong
.get("/route", handler)
.onBeforeHandle(middleware) // too late
// Right
.onBeforeHandle(middleware)
.get("/route", handler) // works
Debug with onRequest: Only onRequest
consistently fires, so use it for debugging:
.onRequest(async ({ request }) => {
console.log("Hit:", request.url);
})
Final Code That Works
// Middleware function
const authMiddleware = createAuthMiddleware(dbService);
// Controller with protected routes
return new Elysia({ name: "youtube" })
.guard({
beforeHandle: authMiddleware
}, (app) => {
return app
.get("/youtube/trending/videos", async () => {
// This route is protected
})
.get("/youtube/search", async ({ query }) => {
// This route is protected
});
});
Testing
# No API key = 401
curl http://localhost:3000/youtube/trending/videos
# With API key = works
curl -H "X-API-Key: your-key" http://localhost:3000/youtube/trending/videos
# Generate API key
bun run create-apikey
Results
After deploying this:
- Unauthorized access was blocked immediately
- Could track which endpoints got hit most
- No performance impact (Elysia is still fast)
- API was properly secured
Takeaways
- Add auth early - Don't wait until your API gets abused
- Guards work - Use
guard
+beforeHandle
for Elysia - Test your middleware - onRequest fires, others might not
- RTFM - Each framework has its own patterns
TL;DR
Use guard
+ beforeHandle
for middleware, not plugins with lifecycle hooks.
Saved my API from getting abused. Sometimes reading the docs (properly) helps.