Skip to main content

Add SOC 2 Compliance Features to Your Boilerplate 2026

·StarterPick Team
soc2compliancesecurityaudit-logsaas-boilerplate2026

TL;DR

SOC 2 compliance is increasingly required for B2B SaaS sales. The core technical controls: audit logging (every data access and modification), RBAC (role-based access), data encryption at rest + in transit, session management, and incident logging. None of these require expensive tools — you can implement SOC 2-ready controls in your boilerplate with Postgres + your existing stack.

Key Takeaways

  • Audit logs: Every sensitive action logged with user, timestamp, action, before/after state
  • RBAC: Role-based access — don't check permissions ad-hoc, use a policy layer
  • Encryption: TLS in transit (Vercel/Railway handles this), encrypted columns for PII
  • Session management: Configurable session timeouts, IP logging, device tracking
  • Vanta/Drata: Compliance automation tools that connect to your infra ($500-2000/month)

Audit Logging

// schema.prisma:
model AuditLog {
  id          String   @id @default(cuid())
  userId      String?
  action      String   // "user.created", "invoice.deleted", "settings.updated"
  resource    String   // "user", "invoice", "settings"
  resourceId  String?
  metadata    Json?    // Before/after state, IP, user agent
  ipAddress   String?
  userAgent   String?
  createdAt   DateTime @default(now())
  
  @@index([userId, createdAt])
  @@index([resource, resourceId])
  @@index([createdAt])
}
// lib/audit.ts — centralized audit logging:
import { headers } from 'next/headers';

type AuditAction =
  | 'user.created' | 'user.updated' | 'user.deleted'
  | 'subscription.created' | 'subscription.cancelled'
  | 'invoice.created' | 'invoice.paid'
  | 'settings.updated' | 'member.invited' | 'member.removed'
  | 'api_key.created' | 'api_key.revoked'
  | 'data.exported' | 'data.deleted';

export async function auditLog({
  userId,
  action,
  resource,
  resourceId,
  metadata,
}: {
  userId?: string;
  action: AuditAction;
  resource: string;
  resourceId?: string;
  metadata?: Record<string, unknown>;
}) {
  const headersList = headers();
  const ipAddress = headersList.get('x-forwarded-for')?.split(',')[0] ?? 
                    headersList.get('x-real-ip');
  const userAgent = headersList.get('user-agent');

  // Fire-and-forget (don't await — don't block request):
  db.auditLog.create({
    data: {
      userId,
      action,
      resource,
      resourceId,
      metadata: metadata as any,
      ipAddress,
      userAgent,
    },
  }).catch(console.error);
}
// Use throughout your app:

// User created:
await auditLog({ userId: admin.id, action: 'user.created', resource: 'user', resourceId: newUser.id });

// Settings changed:
await auditLog({
  userId: session.user.id,
  action: 'settings.updated',
  resource: 'settings',
  metadata: { before: oldSettings, after: newSettings },
});

// Data exported:
await auditLog({
  userId: session.user.id,
  action: 'data.exported',
  resource: 'users',
  metadata: { format: 'csv', rowCount: users.length },
});

RBAC (Role-Based Access Control)

// lib/permissions.ts — centralized permission system:
export type Role = 'owner' | 'admin' | 'member' | 'viewer';

export const PERMISSIONS = {
  'users:read': ['owner', 'admin', 'member', 'viewer'],
  'users:write': ['owner', 'admin'],
  'users:delete': ['owner'],
  'billing:read': ['owner', 'admin'],
  'billing:manage': ['owner'],
  'settings:read': ['owner', 'admin', 'member', 'viewer'],
  'settings:write': ['owner', 'admin'],
  'api_keys:manage': ['owner', 'admin'],
  'data:export': ['owner', 'admin'],
  'members:invite': ['owner', 'admin'],
  'members:remove': ['owner', 'admin'],
} as const satisfies Record<string, Role[]>;

type Permission = keyof typeof PERMISSIONS;

export function hasPermission(role: Role, permission: Permission): boolean {
  return PERMISSIONS[permission].includes(role);
}

// Middleware-style permission check:
export async function requirePermission(
  userId: string,
  organizationId: string,
  permission: Permission
) {
  const membership = await db.organizationMember.findUnique({
    where: { userId_organizationId: { userId, organizationId } },
  });

  if (!membership) throw new Error('Not a member of this organization');
  if (!hasPermission(membership.role as Role, permission)) {
    throw new Error(`Insufficient permissions: ${permission}`);
  }

  return membership;
}
// Use in API routes:
export async function DELETE(
  req: Request,
  { params }: { params: { userId: string } }
) {
  const session = await auth();
  if (!session?.user) return new Response('Unauthorized', { status: 401 });

  // Check permission before acting:
  await requirePermission(session.user.id, params.orgId, 'users:delete');

  await db.user.delete({ where: { id: params.userId } });
  await auditLog({ userId: session.user.id, action: 'user.deleted', resource: 'user', resourceId: params.userId });

  return Response.json({ success: true });
}

Session Security

// lib/session-config.ts — session settings for SOC 2:
export const SESSION_CONFIG = {
  maxAge: 8 * 60 * 60,  // 8-hour sessions (SOC 2 recommended)
  updateAge: 60 * 60,   // Refresh if active within 1 hour of expiry
  
  // Cookie settings:
  cookieOptions: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    sameSite: 'lax' as const,
  },
};

// Track active sessions:
model Session {
  id         String   @id @default(cuid())
  userId     String
  token      String   @unique
  ipAddress  String?
  userAgent  String?
  createdAt  DateTime @default(now())
  lastSeenAt DateTime @default(now())
  expiresAt  DateTime
  revoked    Boolean  @default(false)
}

// Revoke all sessions for a user (on password change, suspicious activity):
export async function revokeAllSessions(userId: string) {
  await db.session.updateMany({
    where: { userId },
    data: { revoked: true },
  });
  await auditLog({ userId, action: 'settings.updated', resource: 'sessions', metadata: { action: 'all_sessions_revoked' } });
}

Audit Log API for Dashboard

// app/api/audit-logs/route.ts:
export async function GET(request: Request) {
  const session = await auth();
  await requirePermission(session!.user.id, getOrgId(request), 'settings:read');

  const { searchParams } = new URL(request.url);
  const logs = await db.auditLog.findMany({
    where: {
      userId: searchParams.get('userId') ?? undefined,
      resource: searchParams.get('resource') ?? undefined,
      createdAt: {
        gte: searchParams.get('from') ? new Date(searchParams.get('from')!) : undefined,
        lte: searchParams.get('to') ? new Date(searchParams.get('to')!) : undefined,
      },
    },
    orderBy: { createdAt: 'desc' },
    take: 100,
  });

  return Response.json(logs);
}

SOC 2 Compliance Checklist

Technical controls (implement in code):
  [x] Audit logging for all sensitive operations
  [x] RBAC with minimum necessary access
  [x] HTTPS enforced (handled by Vercel/Railway)
  [x] Session timeouts (8 hours or less)
  [x] Password complexity (via auth provider)
  [x] 2FA support (Clerk, Auth.js with TOTP)
  [x] Encrypted env variables (Vercel/Railway handle)
  [ ] Data encryption at rest (DB-level, or column encryption)
  [ ] Log retention (90 days minimum, 1 year for SOC 2)
  [ ] Incident response runbook

Tools for formal SOC 2:
  → Vanta ($500/month) — connects to AWS/GCP/GitHub/Vercel
  → Drata ($1000/month) — more enterprise features
  → Secureframe ($500/month) — similar to Vanta
  → Worth it when first enterprise customer asks for SOC 2 report

Find enterprise-ready SaaS boilerplates at StarterPick.

Comments