Overview
All API routes support two authentication methods: session-based (browser) and API key (programmatic). The withAuth() wrapper handles both automatically, enforcing permissions and providing a unified context.
Session Auth
Browser-based authentication via Better Auth cookies. Used for interactive user sessions in the web application.
API Key Auth
Programmatic access via X-API-Key header. Used for external integrations, CI/CD, and third-party tools.
Key Features
- withAuth() wrapper handles both auth types automatically
- Access levels: READ_ONLY, READ_WRITE, FULL
- Feature-based permissions (projects, resources, etc.)
- RLS context set automatically by withApiLogging()
- Session-only mode for sensitive routes
Quick Start
Standard API Route
Basic API route with dual authentication support.
// Standard API route with dual auth support
import { withAuth } from '@/lib/api-auth';
import { withApiLogging } from '@/lib/api-logging';
import { NextResponse } from 'next/server';
export const GET = withApiLogging(
withAuth({ scope: 'project' }, async (request, { auth, actor, projectId }) => {
// RLS already filtering queries - just run business logic
const data = await services.getData(projectId);
return NextResponse.json({ ok: true, data });
})
);Auth Options
Available configuration options for withAuth().
// withAuth options
interface AuthOptions {
// Scope to validate from route params
scope?: 'program' | 'project' | 'none';
// Only allow session auth (reject API keys)
sessionOnly?: boolean;
// Allow publishable API keys (default: false)
allowPublicKeys?: boolean;
// Required access level for API keys
requiredAccess?: 'read' | 'write' | 'full';
// Required feature access for API keys
requiredFeatures?: string[];
}Patterns
Standard Route with Access Control
Routes with different access levels for read/write/delete.
// Standard route with access control
import { withAuth } from '@/lib/api-auth';
import { withApiLogging } from '@/lib/api-logging';
import { validateRequest } from '@/lib/validation';
// GET - Read operation (no access level required)
export const GET = withApiLogging(
withAuth(
{ scope: 'project', requiredFeatures: ['projects'] },
async (request, { auth, actor, projectId }) => {
const data = await services.getData(projectId);
return NextResponse.json({ ok: true, data });
}
)
);
// POST - Write operation (requires write access)
export const POST = withApiLogging(
withAuth(
{ scope: 'project', requiredAccess: 'write', requiredFeatures: ['projects'] },
async (request, { auth, actor, projectId }) => {
const validation = validateRequest(await request.json(), schema);
if (!validation.success) return validation.error;
const data = await services.create(projectId, validation.data);
return NextResponse.json({ ok: true, data }, { status: 201 });
}
)
);
// DELETE - Full access operation (requires full access)
export const DELETE = withApiLogging(
withAuth(
{ scope: 'project', requiredAccess: 'full', requiredFeatures: ['projects'] },
async (request, { auth, actor, projectId }) => {
await services.delete(projectId);
return NextResponse.json({ ok: true, data: null });
}
)
);Session-Only Routes
Routes that should only accept session auth, not API keys.
// Session-only route (rejects API keys)
export const GET = withApiLogging(
withAuth({ sessionOnly: true }, async (request, { auth }) => {
// Only session auth allowed - API keys get 401
// auth.userId is guaranteed to be set
const user = await services.getUser(auth.userId!);
return NextResponse.json({ ok: true, data: user });
})
);
// Common session-only routes:
// - /api/v1/me (user profile)
// - /api/v1/api-keys/* (key management)
// - /api/v1/api-logs/* (log viewing)Public Routes
Intentionally public endpoints with required documentation.
/**
* @public-route: Invitation validation
*
* This endpoint is intentionally public to allow users to
* validate invitation tokens before they have an account.
*/
export const GET = withApiLogging(async (request: NextRequest) => {
// No withAuth - intentionally public
const token = request.nextUrl.searchParams.get('token');
const invitation = await services.validateToken(token);
return NextResponse.json({ ok: true, data: invitation });
});
// IMPORTANT: Public routes MUST have @public-route comment
// ESLint rule enforces this documentationAuth Context
Understanding the authenticated context passed to handlers.
// Authenticated context available in handler
interface AuthenticatedContext {
auth: {
type: 'session' | 'api_key';
userId: string | null; // Set for session auth
apiKey: ValidatedApiKey | null; // Set for API key auth
session: { user: SessionUser } | null;
};
actor: Actor; // For audit logging
programId?: string; // From route params/query
projectId?: string; // From route params/query
}
// Check auth type in handler
export const GET = withApiLogging(
withAuth({ scope: 'program' }, async (request, { auth, actor }) => {
if (auth.type === 'session') {
// Session auth - auth.userId is set
// Use auth.session?.user for user details
} else {
// API key auth - auth.apiKey is set
// Use auth.apiKey?.id for key details
}
return NextResponse.json({ ok: true, data });
})
);Watch Out
Missing authentication wrapper
Don't
// No authentication (WRONG)
export async function GET(request: Request) {
const data = await services.getData();
return NextResponse.json({ ok: true, data });
}Do
// Using withAuth wrapper (CORRECT)
export const GET = withApiLogging(
withAuth({ scope: 'project' }, async (request, { auth }) => {
const data = await services.getData();
return NextResponse.json({ ok: true, data });
})
);Using old direct session pattern
Don't
// Old pattern - direct session check (WRONG)
import { auth } from '@/lib/middleware/auth';
export async function GET(request: Request) {
const session = await auth();
if (!session) {
throw new AuthenticationError();
}
// ...
}Do
// New pattern - withAuth wrapper (CORRECT)
export const GET = withApiLogging(
withAuth({ scope: 'program' }, async (request, { auth }) => {
// auth context provided automatically
// Supports both session AND API key auth
// ...
})
);Missing requiredAccess on write operations
Don't
// Missing requiredAccess on write (WRONG)
export const POST = withApiLogging(
withAuth({ scope: 'project' }, async (request, { auth }) => {
// API key with READ_ONLY could do this!
await services.create(data);
})
);Do
// With requiredAccess (CORRECT)
export const POST = withApiLogging(
withAuth(
{ scope: 'project', requiredAccess: 'write' },
async (request, { auth }) => {
// Only keys with READ_WRITE or FULL can access
await services.create(data);
}
)
);- Public routes MUST have @public-route comment explaining why
- DELETE operations MUST use requiredAccess: 'full'
- POST/PUT/PATCH MUST use requiredAccess: 'write'