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#
- ✅ Always validate session - Use
requireAuth()on protected routes - ✅ Check resource ownership - Verify user owns the resource they're modifying
- ✅ Use role hierarchy - Don't hardcode role checks everywhere
- ✅ Log auth events - Track admin elevation, impersonation
- ✅ Dev mode features - adminKey and impersonation for testing
- ✅ Type safety - Extend Express Request with session types
- ✅ 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#
- Request Validation - Validate authenticated requests
- Complete Request Flow - See full auth + validation flow
- Better Auth Docs - Deep dive into Better Auth