ShipVeryFastShipVeryFast
Documentation

Audit logging & monitoring

ShipVeryFast records security-relevant events, rate-limit hits, CSRF attempts, suspicious requests, auth outcomes, through a single securityLogger singleton, exposes them through admin-only audit and alert APIs, and ships an AuditTrail component for viewing them. This page covers how that pipeline is wired and how to log your own events.

The securityLogger in libs/securityLogger.ts

Everything funnels through a single exported instance, securityLogger, defined in libs/securityLogger.ts. It is a lazy singleton (SecurityLogger.getInstance()) so the same logger is shared across the app, middleware, and API routes.

Two enums describe every event. SecurityEventType categorises what happened, and SecurityEventSeverity describes how bad it is:

SecurityEventTypeMeaning
AUTH_SUCCESSA user authenticated successfully.
AUTH_FAILUREAn authentication attempt failed.
RATE_LIMIT_EXCEEDEDA caller tripped a rate limiter.
PERMISSION_DENIEDAn authorization check rejected the request.
SUSPICIOUS_ACTIVITYProbe paths, injection-looking params, CSRF anomalies.
CONFIGURATION_CHANGEA security-relevant setting changed.
API_ACCESSA sensitive API endpoint was accessed.
DATA_ACCESSData was read or exported.

SecurityEventSeverity is ordered INFOWARNINGERROR CRITICAL. That order matters: the logger triggers an alert whenever an event's severity is at or above its alertThreshold, which defaults to SecurityEventSeverity.ERROR.

Each logged event is a SecurityEvent, the call supplies everything except timestamp, which the logger stamps with new Date().toISOString():

interface SecurityEvent {
  timestamp: string;          // added by the logger
  type: SecurityEventType;
  severity: SecurityEventSeverity;
  message: string;
  userId?: string;
  ip?: string;
  userAgent?: string;
  path?: string;
  method?: string;
  metadata?: Record<string, any>;
}

What gets logged, and where

Events are written from two runtimes, and the logger adapts to each. In the Node runtime it appends one JSON line per event to logs/security.log (the directory is created on first write), and also mirrors to the console in development. In the Edge runtime, Next.js middleware and anything it imports, there is no filesystem, so the logger detects process.env.NEXT_RUNTIME === 'edge' and emits to the platform log stream via logger.warn instead. Either way, the alert check still runs.

The boilerplate already logs these events for you:

EventType / SeveritySource
Rate limit exceeded for an API routeRATE_LIMIT_EXCEEDED / WARNINGmiddleware.ts
Invalid origin on a mutating API requestSUSPICIOUS_ACTIVITY / ERRORmiddleware.ts (CSRF)
Missing or invalid CSRF tokenSUSPICIOUS_ACTIVITY / ERRORmiddleware.ts (CSRF)
User authenticatedAUTH_SUCCESS / INFOmiddleware.ts (authorized callback)
Probe path (e.g. /.env, /.git/)SUSPICIOUS_ACTIVITY / WARNINGmiddleware/securityMonitor.ts
Injection-looking query paramsSUSPICIOUS_ACTIVITY / WARNINGmiddleware/securityMonitor.ts
Sensitive API accessed (auth/user/subscription)API_ACCESS / INFOmiddleware/securityMonitor.ts
AI chat rate limit / model accessRATE_LIMIT_EXCEEDED / API_ACCESSapp/api/ai/chat/route.ts

Alerting is a stub. When severity meets the threshold, triggerAlert currently just calls logger.critical(...) with a [SECURITY ALERT] prefix. The code marks the real delivery (email, Slack, incident workflow) as a TODO, wire that up before you rely on it in production.

The security middleware monitor

securityMonitor(req) in middleware/securityMonitor.ts is the first thing the main middleware runs (before CSRF and rate limiting). It inspects the request path and query string and short-circuits obvious probes:

  • Paths matching its SUSPICIOUS_PATTERNS list (such as /.env, /.git/, /wp-admin, /etc/passwd) are logged as SUSPICIOUS_ACTIVITY and answered with a 404.
  • Query params that look like SQL injection, or contain <script / javascript: / data:text/html, or use names like exec / eval / system, are logged and answered with a 400.
  • Requests to /api/auth, /api/user, or /api/subscription are recorded as API_ACCESS (severity INFO) and allowed through.

When the monitor returns a response, the main middleware returns it immediately:

// middleware.ts
const securityResponse = securityMonitor(req);
if (securityResponse) return securityResponse;

Admin-only audit & alert APIs

Two App Router routes expose the log to administrators. Both app/api/security/audit/route.ts and app/api/security/alerts/route.ts gate every handler with the same check.

RouteMethodDoes
/api/security/auditGETReturns logged events. Query params limit (1 to 1000, default 100), offset, and an optional type filter (a SecurityEventType). Responds with { logs, total, limit, offset }.
/api/security/auditPOSTLogs a custom event. Body is validated with Zod; type and severity are constrained to the enums.
/api/security/alertsGETReturns the current alert threshold.
/api/security/alertsPOSTSets the threshold via securityLogger.setAlertThreshold(...). Body: { threshold } (a SecurityEventSeverity).

The admin gate

Authorization runs through isAdmin(session) from libs/admin.ts, which checks the session email against the ADMIN_EMAILS environment variable (a comma-separated allowlist set in .env alongside the other secrets). It fails closed: if ADMIN_EMAILS is unset or empty, nobody is treated as an admin.

// every handler in app/api/security/*/route.ts
const session = await getServerSession(authOptions);
if (!isAdmin(session)) {
  return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}

Set the allowlist before you expect these routes to work:

# .env.local
ADMIN_EMAILS=admin@example.com,you@yourdomain.com

Note that the alerts route mirrors the threshold in a module-level variable so its GET can report what POST last set, the logger exposes only a setter, no getter.

The AuditTrail component

components/security/AuditTrail.tsx (exported from components/security) is a client component that fetches /api/security/audit and renders the events in a paginated table with per-type icons and severity colour-coding. Because it calls the admin-gated route, only an authenticated admin will see data.

import { AuditTrail } from "@/components/security";

function SecurityDashboard() {
  return (
    <AuditTrail
      limit={50}
      autoRefresh={true}
      filterType="SUSPICIOUS_ACTIVITY"
    />
  );
}

Its props: limit (page size, default 100), autoRefresh (re-fetch every 30 seconds, default false), and an optional filterType of type SecurityEventType. The component is available to drop into any admin screen, it is not mounted by default, so add it where you want the trail to appear.

Logging a new security event

From any Node-runtime route or server module, import the singleton and call securityLogger.log(...) with an event minus its timestamp. Here is the exact pattern the AI chat route uses when it records a rate-limit hit:

import {
  securityLogger,
  SecurityEventType,
  SecurityEventSeverity,
} from "@/libs/securityLogger";

securityLogger.log({
  type: SecurityEventType.RATE_LIMIT_EXCEEDED,
  severity: SecurityEventSeverity.WARNING,
  message: "AI chat rate limit exceeded",
  userId: session.user.email,
});

Add the optional ip, userAgent, path, method, and a free-form metadata object when you have them, the audit table surfaces them. Choose ERROR or CRITICAL severity if you want the event to cross the default alert threshold. If your code runs inside Edge middleware, the same call still works; it just writes to the platform log stream instead of logs/security.log.

Alternatively, an admin client can POST to /api/security/auditto record an event without importing the logger, useful from a browser-side admin tool. The route stamps in the admin's userId, ip, and userAgent automatically.

Next steps

  • Rate limiting, the limiters that feed RATE_LIMIT_EXCEEDED events.
  • CSRF protection, the origin and token checks that log SUSPICIOUS_ACTIVITY.
  • Admin dashboard, where the AuditTrail component and ADMIN_EMAILS gate come together.