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:
SecurityEventType | Meaning |
|---|---|
AUTH_SUCCESS | A user authenticated successfully. |
AUTH_FAILURE | An authentication attempt failed. |
RATE_LIMIT_EXCEEDED | A caller tripped a rate limiter. |
PERMISSION_DENIED | An authorization check rejected the request. |
SUSPICIOUS_ACTIVITY | Probe paths, injection-looking params, CSRF anomalies. |
CONFIGURATION_CHANGE | A security-relevant setting changed. |
API_ACCESS | A sensitive API endpoint was accessed. |
DATA_ACCESS | Data was read or exported. |
SecurityEventSeverity is ordered INFO → WARNING → ERROR → 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:
| Event | Type / Severity | Source |
|---|---|---|
| Rate limit exceeded for an API route | RATE_LIMIT_EXCEEDED / WARNING | middleware.ts |
| Invalid origin on a mutating API request | SUSPICIOUS_ACTIVITY / ERROR | middleware.ts (CSRF) |
| Missing or invalid CSRF token | SUSPICIOUS_ACTIVITY / ERROR | middleware.ts (CSRF) |
| User authenticated | AUTH_SUCCESS / INFO | middleware.ts (authorized callback) |
Probe path (e.g. /.env, /.git/) | SUSPICIOUS_ACTIVITY / WARNING | middleware/securityMonitor.ts |
| Injection-looking query params | SUSPICIOUS_ACTIVITY / WARNING | middleware/securityMonitor.ts |
| Sensitive API accessed (auth/user/subscription) | API_ACCESS / INFO | middleware/securityMonitor.ts |
| AI chat rate limit / model access | RATE_LIMIT_EXCEEDED / API_ACCESS | app/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_PATTERNSlist (such as/.env,/.git/,/wp-admin,/etc/passwd) are logged asSUSPICIOUS_ACTIVITYand answered with a404. - Query params that look like SQL injection, or contain
<script/javascript:/data:text/html, or use names likeexec/eval/system, are logged and answered with a400. - Requests to
/api/auth,/api/user, or/api/subscriptionare recorded asAPI_ACCESS(severityINFO) 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.
| Route | Method | Does |
|---|---|---|
/api/security/audit | GET | Returns 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/audit | POST | Logs a custom event. Body is validated with Zod; type and severity are constrained to the enums. |
/api/security/alerts | GET | Returns the current alert threshold. |
/api/security/alerts | POST | Sets 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.comNote 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_EXCEEDEDevents. - CSRF protection, the origin and token checks that log
SUSPICIOUS_ACTIVITY. - Admin dashboard, where the
AuditTrailcomponent andADMIN_EMAILSgate come together.
