CentercodePorygon
DittoPorygonAPI

Authentication

Dual auth: Session + API Key

Safe

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 documentation

Auth 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'

Related

API Keys

Key types, permissions, creation

Authorization

Role and scope checks

API Logging

withApiLogging wrapper