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

  1. Add auth early - Don't wait until your API gets abused
  2. Guards work - Use guard + beforeHandle for Elysia
  3. Test your middleware - onRequest fires, others might not
  4. 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.