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.