bknd-protect-endpoint

Use when securing specific API endpoints in Bknd. Covers protecting custom HTTP triggers, plugin routes, auth middleware for Flows, checking permissions in custom endpoints, and role-based endpoint access.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "bknd-protect-endpoint" with this command: npx skills add cameronapak/bknd-skills/cameronapak-bknd-skills-bknd-protect-endpoint

Protect Endpoint

Secure specific API endpoints with authentication and authorization checks.

Prerequisites

  • Bknd project with code-first configuration
  • Auth enabled (auth: { enabled: true })
  • Guard enabled for authorization (guard: { enabled: true })
  • Roles defined (see bknd-create-role)

When to Use UI Mode

  • Viewing registered routes: Admin Panel > System > Debug
  • Inspecting role permissions

Note: Endpoint protection requires code mode. UI is read-only.

When to Use Code Mode

  • Creating protected custom endpoints
  • Adding auth checks to HTTP triggers
  • Building protected plugin routes
  • Implementing endpoint-specific permissions

Code Approach

Understanding Endpoint Types

Bknd has several endpoint types to protect:

TypePath PatternHow to Protect
Data API/api/data/*Guard permissions (automatic)
Auth API/api/auth/*Built-in protection
Media API/api/media/*Guard permissions (automatic)
HTTP TriggersCustom pathsManual auth check
Plugin RoutesCustom pathsManual auth check

Step 1: Protect HTTP Trigger (Flow)

Add authentication to a custom endpoint via FunctionTask:

import { serve } from "bknd/adapter/bun";
import { Flow, HttpTrigger, FunctionTask } from "bknd";

// Protected endpoint flow
const protectedFlow = new Flow("protected-endpoint", [
  new FunctionTask({
    name: "checkAuth",
    handler: async (input, ctx) => {
      // ctx.app gives access to modules
      const authModule = ctx.app.modules.get("auth");
      const user = await authModule.authenticator.getUserFromRequest(input);

      if (!user) {
        throw new Response(JSON.stringify({ error: "Unauthorized" }), {
          status: 401,
          headers: { "Content-Type": "application/json" },
        });
      }

      // Pass user to next task
      return { user, body: await input.json() };
    },
  }),
  new FunctionTask({
    name: "processRequest",
    handler: async (input) => {
      // input contains { user, body } from previous task
      return {
        message: `Hello ${input.user.email}`,
        data: input.body,
      };
    },
  }),
]);

protectedFlow.setTrigger(
  new HttpTrigger({
    path: "/api/custom/protected",
    method: "POST",
    respondWith: "processRequest",
  })
);

serve({
  connection: { url: "file:data.db" },
  config: {
    flows: {
      flows: [protectedFlow],
    },
  },
});

Step 2: Protect Plugin Route

Add auth check in plugin's onServerInit:

import { serve } from "bknd/adapter/bun";
import { createPlugin } from "bknd";

const protectedPlugin = createPlugin({
  name: "protected-routes",

  onServerInit: (server) => {
    // Protected endpoint
    server.post("/api/custom/data", async (c) => {
      // Get app from context
      const app = c.get("app");
      const authModule = app.modules.get("auth");

      // Resolve user from request
      const user = await authModule.authenticator.getUserFromRequest(c.req.raw);

      if (!user) {
        return c.json({ error: "Unauthorized" }, 401);
      }

      // Proceed with protected logic
      const body = await c.req.json();
      return c.json({
        message: "Protected data",
        user: user.email,
        received: body,
      });
    });

    // Public endpoint (no auth check)
    server.get("/api/custom/public", (c) => {
      return c.json({ message: "Public data" });
    });
  },
});

serve({
  connection: { url: "file:data.db" },
  plugins: [protectedPlugin],
});

Step 3: Role-Based Endpoint Protection

Check user's role for specific permissions:

const roleProtectedPlugin = createPlugin({
  name: "role-protected",

  onServerInit: (server) => {
    // Admin-only endpoint
    server.delete("/api/admin/users/:id", async (c) => {
      const app = c.get("app");
      const authModule = app.modules.get("auth");
      const user = await authModule.authenticator.getUserFromRequest(c.req.raw);

      // Check authentication
      if (!user) {
        return c.json({ error: "Unauthorized" }, 401);
      }

      // Check role
      if (user.role !== "admin") {
        return c.json({ error: "Forbidden: Admin role required" }, 403);
      }

      // Proceed with admin action
      const userId = c.req.param("id");
      // ... delete user logic
      return c.json({ deleted: userId });
    });
  },
});

Step 4: Permission-Based Protection with Guard

Use Guard for granular permission checks:

import { createPlugin, DataPermissions } from "bknd";

const guardProtectedPlugin = createPlugin({
  name: "guard-protected",

  onServerInit: (server) => {
    server.post("/api/custom/sync", async (c) => {
      const app = c.get("app");
      const authModule = app.modules.get("auth");
      const guard = authModule.guard;

      const user = await authModule.authenticator.getUserFromRequest(c.req.raw);

      if (!user) {
        return c.json({ error: "Unauthorized" }, 401);
      }

      // Check specific permission using Guard
      try {
        guard.granted(
          DataPermissions.databaseSync,  // Permission to check
          { role: user.role },           // User context
          {}                             // Permission context
        );
      } catch (error) {
        return c.json({
          error: "Forbidden",
          message: error.message,
        }, 403);
      }

      // User has permission - proceed
      return c.json({ status: "sync started" });
    });
  },
});

Step 5: Entity-Specific Permission Check

Check permissions for specific entity operations:

server.post("/api/custom/posts/batch", async (c) => {
  const app = c.get("app");
  const authModule = app.modules.get("auth");
  const guard = authModule.guard;

  const user = await authModule.authenticator.getUserFromRequest(c.req.raw);

  if (!user) {
    return c.json({ error: "Unauthorized" }, 401);
  }

  // Check create permission for posts entity
  try {
    guard.granted(
      DataPermissions.entityCreate,
      { role: user.role },
      { entity: "posts" }  // Entity-specific context
    );
  } catch (error) {
    return c.json({
      error: "Cannot create posts",
      message: error.message,
    }, 403);
  }

  // Proceed with batch creation
  const body = await c.req.json();
  // ... create posts
  return c.json({ created: body.length });
});

Step 6: Reusable Auth Middleware

Create a helper for consistent auth checks:

// auth-middleware.ts
type AuthContext = {
  user: any;
  role: string;
};

export async function requireAuth(
  c: any,
  app: any
): Promise<AuthContext | Response> {
  const authModule = app.modules.get("auth");
  const user = await authModule.authenticator.getUserFromRequest(c.req.raw);

  if (!user) {
    return c.json({ error: "Unauthorized" }, 401);
  }

  return { user, role: user.role };
}

export async function requireRole(
  c: any,
  app: any,
  allowedRoles: string[]
): Promise<AuthContext | Response> {
  const result = await requireAuth(c, app);

  if (result instanceof Response) {
    return result;
  }

  if (!allowedRoles.includes(result.role)) {
    return c.json({
      error: "Forbidden",
      required: allowedRoles,
      current: result.role,
    }, 403);
  }

  return result;
}

// Usage in plugin
server.get("/api/reports/admin", async (c) => {
  const app = c.get("app");
  const auth = await requireRole(c, app, ["admin", "manager"]);

  if (auth instanceof Response) return auth;

  // auth.user available
  return c.json({ reports: [] });
});

Step 7: Protecting Flow with Auth Task

Create reusable auth task for Flows:

import { Flow, HttpTrigger, FunctionTask } from "bknd";

// Reusable auth task
const authTask = new FunctionTask({
  name: "requireAuth",
  handler: async (input, ctx) => {
    const authModule = ctx.app.modules.get("auth");
    const user = await authModule.authenticator.getUserFromRequest(input);

    if (!user) {
      throw new Response(
        JSON.stringify({ error: "Unauthorized" }),
        { status: 401, headers: { "Content-Type": "application/json" } }
      );
    }

    return { request: input, user };
  },
});

// Reusable role check task
const requireAdmin = new FunctionTask({
  name: "requireAdmin",
  handler: async (input) => {
    if (input.user.role !== "admin") {
      throw new Response(
        JSON.stringify({ error: "Admin required" }),
        { status: 403, headers: { "Content-Type": "application/json" } }
      );
    }
    return input;
  },
});

// Protected flow
const adminFlow = new Flow("admin-action", [
  authTask,
  requireAdmin,
  new FunctionTask({
    name: "performAction",
    handler: async (input) => {
      return { success: true, admin: input.user.email };
    },
  }),
]);

adminFlow.setTrigger(
  new HttpTrigger({
    path: "/api/admin/action",
    method: "POST",
    respondWith: "performAction",
  })
);

Common Patterns

Optional Auth (Public with Extra Features)

server.get("/api/posts", async (c) => {
  const app = c.get("app");
  const authModule = app.modules.get("auth");
  const api = app.getApi();

  // Try to get user (may be null)
  const user = await authModule.authenticator.getUserFromRequest(c.req.raw);

  if (user) {
    // Authenticated: show all posts including drafts
    const posts = await api.data.readMany("posts", {
      where: {
        $or: [
          { status: "published" },
          { author_id: user.id },
        ],
      },
    });
    return c.json(posts.data);
  } else {
    // Anonymous: show only published
    const posts = await api.data.readMany("posts", {
      where: { status: "published" },
    });
    return c.json(posts.data);
  }
});

Rate-Limited Protected Endpoint

const rateLimits = new Map<string, { count: number; reset: number }>();

server.post("/api/expensive-operation", async (c) => {
  const app = c.get("app");
  const authModule = app.modules.get("auth");
  const user = await authModule.authenticator.getUserFromRequest(c.req.raw);

  if (!user) {
    return c.json({ error: "Unauthorized" }, 401);
  }

  // Simple rate limiting by user
  const key = `user:${user.id}`;
  const now = Date.now();
  const limit = rateLimits.get(key);

  if (limit && limit.reset > now && limit.count >= 10) {
    return c.json({
      error: "Rate limit exceeded",
      retryAfter: Math.ceil((limit.reset - now) / 1000),
    }, 429);
  }

  // Update rate limit
  if (!limit || limit.reset < now) {
    rateLimits.set(key, { count: 1, reset: now + 60000 });
  } else {
    limit.count++;
  }

  // Proceed
  return c.json({ result: "success" });
});

API Key Authentication

For service-to-service or external API access:

const API_KEYS = new Set([
  process.env.SERVICE_API_KEY,
  process.env.PARTNER_API_KEY,
]);

server.post("/api/webhook/external", async (c) => {
  const apiKey = c.req.header("X-API-Key");

  if (!apiKey || !API_KEYS.has(apiKey)) {
    return c.json({ error: "Invalid API key" }, 401);
  }

  // Proceed with webhook handling
  const body = await c.req.json();
  return c.json({ received: true });
});

Verification

1. Test Unauthenticated Access

# Should return 401
curl -X POST http://localhost:7654/api/custom/protected \
  -H "Content-Type: application/json" \
  -d '{"test": "data"}'

2. Test Authenticated Access

# Login first
TOKEN=$(curl -s -X POST http://localhost:7654/api/auth/password/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@test.com", "password": "pass123"}' | jq -r '.token')

# Access protected endpoint
curl -X POST http://localhost:7654/api/custom/protected \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"test": "data"}'

3. Test Role Restriction

# Login as non-admin
TOKEN=$(curl -s -X POST http://localhost:7654/api/auth/password/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@test.com", "password": "pass123"}' | jq -r '.token')

# Should return 403
curl -X DELETE http://localhost:7654/api/admin/users/1 \
  -H "Authorization: Bearer $TOKEN"

4. Verify with Admin Role

# Login as admin
ADMIN_TOKEN=$(curl -s -X POST http://localhost:7654/api/auth/password/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@test.com", "password": "admin123"}' | jq -r '.token')

# Should succeed
curl -X DELETE http://localhost:7654/api/admin/users/1 \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Common Pitfalls

User Always Null

Problem: getUserFromRequest() returns null even with valid token

Fix: Ensure token is sent correctly:

// Header auth
fetch("/api/custom/protected", {
  headers: { "Authorization": `Bearer ${token}` }
});

// OR cookie auth (if using cookies)
fetch("/api/custom/protected", {
  credentials: "include"  // Send cookies
});

Guard Not Available

Problem: authModule.guard is undefined

Fix: Ensure guard is enabled:

{
  auth: {
    enabled: true,
    guard: { enabled: true },  // Required!
  },
}

Permission Check Throws Wrong Error

Problem: Guard throws unexpected error type

Fix: Catch specific exception:

import { GuardPermissionsException } from "bknd";

try {
  guard.granted(permission, context, permContext);
} catch (error) {
  if (error instanceof GuardPermissionsException) {
    return c.json({ error: error.message }, 403);
  }
  throw error;  // Re-throw unexpected errors
}

CORS Blocking Auth Header

Problem: Preflight fails for Authorization header

Fix: Configure CORS:

serve({
  // ...
  config: {
    server: {
      cors: {
        origin: ["http://localhost:3000"],
        credentials: true,
        allowHeaders: ["Authorization", "Content-Type"],
      },
    },
  },
});

Flow Task Doesn't Have App Context

Problem: ctx.app undefined in FunctionTask

Fix: Access via execution context:

new FunctionTask({
  name: "withApp",
  handler: async (input, ctx) => {
    // ctx.app is available in FunctionTask
    const app = ctx.app;
    // ...
  },
});

DOs and DON'Ts

DO:

  • Always check auth before processing sensitive requests
  • Use Guard for permission checks (consistent with Bknd's system)
  • Return appropriate HTTP status codes (401, 403)
  • Create reusable auth helpers for consistency
  • Log auth failures for security monitoring

DON'T:

  • Trust client-provided user IDs without verification
  • Expose detailed error messages about auth failures
  • Skip auth checks assuming "internal" endpoints are safe
  • Store sensitive data in JWT payload (use user ID only)
  • Forget to handle both header and cookie auth methods

Related Skills

  • bknd-create-role - Define roles for authorization
  • bknd-assign-permissions - Configure role permissions
  • bknd-public-vs-auth - Public vs authenticated access
  • bknd-row-level-security - Data-level access control
  • bknd-custom-endpoint - Create custom API endpoints

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

btca-bknd-repo-learn

No summary provided by upstream source.

Repository SourceNeeds Review
General

bknd-registration

No summary provided by upstream source.

Repository SourceNeeds Review
General

bknd-bulk-operations

No summary provided by upstream source.

Repository SourceNeeds Review
General

bknd-login-flow

No summary provided by upstream source.

Repository SourceNeeds Review