Skip to content

Authentication & Authorization#

Protect routes with Better Auth, check user roles, and handle sessions.

Auth Middleware#

import { fromNodeHeaders } from "better-auth/node";
import type { NextFunction, Request, Response } from "express";
import { StatusCodes } from "http-status-codes";
import { auth } from "../auth/auth.js";
import config from "../config.js";
import { getUserById } from "../db/db.js";
import { UserRoles } from "../schemas/enums.js";
import logger from "../utils/logger.js";

export function requireAuth(...allowedRoles: string[]) {
  return async (req: Request, res: Response, next: NextFunction) => {
    // Get session from Better Auth
    const session = await auth.api.getSession({
      headers: fromNodeHeaders(req.headers),
    });

    if (!session) {
      return res
        .status(StatusCodes.UNAUTHORIZED)
        .json({ error: "No auth session" });
    }

    let requestor = session.user;
    let isAdmin = requestor.roles.includes(UserRoles.ADMIN);

    // Dev mode: Become admin with query param
    if (config.devMode && req.query.adminKey === config.devModeAdminKey) {
      logger.debug(
        `${requestor.id} (${requestor.email}) elevated to admin using adminKey`
      );
      isAdmin = true;
    }

    // Admin impersonation (works in production too)
    if (isAdmin && req.query.impersonatingUser) {
      const impersonatedUser = await getUserById(
        req.query.impersonatingUser as string
      );

      if (!impersonatedUser) {
        return res.status(StatusCodes.BAD_REQUEST).json({
          error: `User ${req.query.impersonatingUser} not found`,
        });
      }

      logger.debug("Impersonating user", {
        impersonatedUserId: impersonatedUser.id,
        impersonatedUserEmail: impersonatedUser.email,
      });

      requestor = impersonatedUser as typeof requestor;
      session.user = requestor;
    }

    // Attach session to request
    req.authSession = session;
    req.requestorId = session.user.id;

    // Check roles
    if (allowedRoles.length > 0 && !isAdmin) {
      const hasRole = requestor.roles.some((r) => allowedRoles.includes(r));

      if (!hasRole) {
        return res
          .status(StatusCodes.FORBIDDEN)
          .json({ error: "Insufficient role" });
      }
    }

    next();
  };
}

// Convenience middleware
export const requireAdmin = requireAuth(UserRoles.ADMIN);
export const requireUser = requireAuth(UserRoles.USER);

Usage in Routes#

import { requireAuth, requireAdmin } from "../middleware/auth.js";

// Any authenticated user
router.get("/profile", requireAuth(), async (req, res) => {
  const userId = req.requestorId;
  const user = await getUserById(userId);
  res.json({ user });
});

// Admin only
router.get("/users", requireAdmin, async (req, res) => {
  const users = await getAllUsers();
  res.json({ users });
});

// Specific roles
router.post("/moderate", requireAuth("MODERATOR", "ADMIN"), async (req, res) => {
  // Only moderators and admins can access
  res.json({ success: true });
});

// Check if request is from resource owner
router.put("/posts/:id", requireAuth(), async (req, res) => {
  const post = await getPostById(req.params.id);

  if (post.authorId !== req.requestorId) {
    return res.status(StatusCodes.FORBIDDEN).json({
      error: "You can only edit your own posts",
    });
  }

  // Update post
});

TypeScript Declarations#

Add session types to Express Request:

// types/express.d.ts
import "express";

declare global {
  namespace Express {
    interface Request {
      authSession?: {
        user: {
          id: string;
          email: string;
          name: string;
          roles: string[];
        };
        session: {
          id: string;
          expiresAt: Date;
        };
      };
      requestorId?: string;
    }
  }
}

Dev Mode Features#

Temporary Admin Access#

Get admin privileges in development with query parameter:

# Normal user becomes admin temporarily
GET /api/admin/users?adminKey=dev-secret-key
// In .env
DEV_MODE_ADMIN_KEY=dev-secret-key

// In auth middleware
if (config.devMode && req.query.adminKey === config.devModeAdminKey) {
  isAdmin = true;
}

User Impersonation#

Admin can impersonate any user (works in production):

# Admin acts as user with ID "user_123"
GET /api/posts?impersonatingUser=user_123
if (isAdmin && req.query.impersonatingUser) {
  const user = await getUserById(req.query.impersonatingUser);
  requestor = user;
  session.user = user;
}

Helper Functions#

// Check if request is from specific user
export function isRequestFromUser(req: Request, userId: string): boolean {
  return req.authSession!.user.id === userId;
}

// Check if user has any of the roles
export function hasAnyRole(req: Request, ...roles: string[]): boolean {
  return req.authSession!.user.roles.some((r) => roles.includes(r));
}

// Check if user owns resource
export function isResourceOwner(req: Request, resourceOwnerId: string): boolean {
  return req.requestorId === resourceOwnerId;
}

Complete Auth Flow#

// 1. User sends request with session cookie
// GET /api/posts
// Cookie: better-auth.session_token=abc123

// 2. requireAuth middleware runs
router.get("/posts", requireAuth(), async (req, res) => {

  // 3. Session is validated
  const session = await auth.api.getSession({
    headers: fromNodeHeaders(req.headers),
  });

  // 4. Session attached to request
  req.authSession = session;
  req.requestorId = session.user.id;

  // 5. Route handler can access user info
  const posts = await getPostsByUserId(req.requestorId);
  res.json({ posts });
});

Role-Based Access Control#

Define Roles#

// schemas/enums.ts
export enum UserRoles {
  ADMIN = "ADMIN",
  MODERATOR = "MODERATOR",
  USER = "USER",
  GUEST = "GUEST",
}

Role Hierarchy#

const roleHierarchy = {
  [UserRoles.ADMIN]: 3,
  [UserRoles.MODERATOR]: 2,
  [UserRoles.USER]: 1,
  [UserRoles.GUEST]: 0,
};

export function hasMinimumRole(
  userRole: UserRoles,
  requiredRole: UserRoles
): boolean {
  return roleHierarchy[userRole] >= roleHierarchy[requiredRole];
}

// Usage
if (!hasMinimumRole(req.authSession.user.role, UserRoles.MODERATOR)) {
  return res.status(403).json({ error: "Insufficient permissions" });
}

Better Auth Integration#

Setup Better Auth Handler#

import { toNodeHandler } from "better-auth/node";
import { auth } from "./auth/auth.js";

// MUST come before express.json()
app.all("/api/auth/*", toNodeHandler(auth));
app.use(express.json()); // After auth handler!

Get Session#

import { fromNodeHeaders } from "better-auth/node";

const session = await auth.api.getSession({
  headers: fromNodeHeaders(req.headers),
});

Error Responses#

// No session
{
  "error": "No auth session"
}
// Status: 401 UNAUTHORIZED

// Wrong role
{
  "error": "Insufficient role"
}
// Status: 403 FORBIDDEN

// Not resource owner
{
  "error": "You can only edit your own posts"
}
// Status: 403 FORBIDDEN

Best Practices#

  1. Always validate session - Use requireAuth() on protected routes
  2. Check resource ownership - Verify user owns the resource they're modifying
  3. Use role hierarchy - Don't hardcode role checks everywhere
  4. Log auth events - Track admin elevation, impersonation
  5. Dev mode features - adminKey and impersonation for testing
  6. Type safety - Extend Express Request with session types
  7. Middleware order - Auth handler before express.json()

Common Patterns#

Optional Auth#

// Try to get session, but don't require it
router.get("/posts", async (req, res) => {
  const session = await auth.api.getSession({
    headers: fromNodeHeaders(req.headers),
  });

  // Show different data based on auth status
  if (session) {
    // Logged in - show personalized posts
    const posts = await getPersonalizedPosts(session.user.id);
  } else {
    // Guest - show public posts
    const posts = await getPublicPosts();
  }

  res.json({ posts });
});

Owner or Admin#

router.delete("/posts/:id", requireAuth(), async (req, res) => {
  const post = await getPostById(req.params.id);
  const isOwner = post.authorId === req.requestorId;
  const isAdmin = req.authSession.user.roles.includes("ADMIN");

  if (!isOwner && !isAdmin) {
    return res.status(403).json({ error: "Not authorized" });
  }

  await deletePost(req.params.id);
  res.json({ success: true });
});

Next Steps#