Security
SaaSyBase ships with production-grade security defaults — HTTP headers, error sanitization, encryption, rate limiting, audit logging, and more. This page explains what's configured and how to extend it.
HTTP Security Headers
The app sets strict HTTP headers via next.config.mjs. These apply to every response served by Next.js:
| Header | Value | Purpose |
|---|---|---|
| X-Frame-Options | DENY | Prevents clickjacking by blocking all framing |
| X-Content-Type-Options | nosniff | Blocks MIME-type sniffing |
| Referrer-Policy | origin-when-cross-origin | Limits referrer leakage |
| X-XSS-Protection | 1; mode=block | Legacy XSS protection for older browsers |
| Permissions-Policy | camera=(), microphone=(), geolocation=(), payment=() | Limits browser API access |
| Strict-Transport-Security | max-age=31536000; includeSubDomains | Forces HTTPS for 1 year |
| Content-Security-Policy | Restrictive source allowlist for scripts, frames, connections, and embeds | Reduces XSS and third-party injection risk while still allowing configured analytics and payment providers |
| Cache-Control (API routes) | no-store, max-age=0 | Disables API response caching |
Tip
next.config.mjs. If you introduce extra browser capabilities or embed contexts, update the headers there so the runtime behavior and docs stay aligned.The CSP is intentionally pragmatic rather than nonce-based: SaaSyBase uses inline theme/bootstrap scripts, admin-configurable head/body snippets, analytics, Twitter embeds, and provider checkout scripts. The policy still restricts script, frame, and connect sources to a known allowlist instead of leaving those channels open.
Error Sanitization
In production, internal error details (stack traces, database errors, validation internals) are never exposed to the client. The error system works in two layers:
API error handling
Use handleApiError(error) from lib/api-error.ts in all API route catch blocks. It returns a clean JSON response with the right status code and a safe message. Sensitive details are logged server-side but stripped from the response.
import { handleApiError } from '@/lib/api-error';
export async function POST(req: Request) {
try {
// ...your logic
} catch (error) {
return handleApiError(error);
}
}Secure error responses
For finer control, use createErrorResponse() from lib/secure-errors.ts. This function:
- Returns generic messages in production, detailed messages in development
- Maps known error types to appropriate HTTP status codes
- Logs sanitized error data via the secure logger
AppError classes
SaaSyBase defines typed error classes for structured error handling:
| Class | Status | Use case |
|---|---|---|
| AppError | Configurable | Base operational error type for safe structured failures |
| ApiError | Configurable | General API errors with status code |
| AuthenticationError | 401 | Missing or invalid credentials |
| AuthorizationError | 403 | Insufficient permissions |
| NotFoundError | 404 | Resource not found |
| ValidationError | 400 | Input validation failures |
| PaymentError | 402 | Billing or provider-related failures |
| RateLimitError | 429 | Too many requests |
Encryption
Set ENCRYPTION_SECRET to a strong random string. This key is used for:
- Encrypting sensitive data at rest, including reusable payment authorization codes stored for provider-managed renewals
- Generating HMACs for internal token verification
Warning
ENCRYPTION_SECRET must stay constant across deployments. Rotating it requires re-encrypting all existing encrypted values. Generate it with openssl rand -hex 32.Note
ENCRYPTION_SECRET and the rest of your server-side secrets in platform-native encrypted envs or in the optional Infisical/Doppler bootstrap instead of shared env files. The setup guide lives in Secrets & Providers.Password Storage & Reset Flow
When using a self-hosted credentials flow, passwords are never stored in plaintext. SaaSyBase hashes credential passwords with bcrypt using 12 salt rounds before they are written to the database.
- Forgot-password tokens are generated with
crypto.randomBytes(32) - Only the SHA-256 hash of the reset token is stored in the database
- Reset links expire after 1 hour and are deleted after use or on expiration
- Password reset increments
tokenVersionand deletes active sessions to force re-authentication - Forgot-password and resend-verification endpoints return generic success messages to reduce email enumeration risk
Note
Webhook Signature Verification
All incoming payment webhooks are signature-verified before processing. The centralized webhook router at /api/webhooks/payments auto-detects the originating provider from request headers and delegates to the correct signature verification routine.
Webhook secrets support rotation via comma-separated values:
STRIPE_WEBHOOK_SECRET="whsec_new_secret,whsec_old_secret"During rotation, both the old and new secrets are accepted. Once you've confirmed the new secret works, remove the old one.
Rate Limiting
Rate limiting is database-backed and distributed-safe (works across serverless instances). Apply it to any API route with a single function call:
import { rateLimit, RATE_LIMITS } from '@/lib/rateLimit';
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
const limited = await rateLimit(ip, RATE_LIMITS.API_GENERAL);
if (limited) return limited; // Returns 429 response
// ...continue
}If x-forwarded-for is missing, every such request falls into the same fallback bucket. In production, make sure your proxy or platform forwards real client IPs so unrelated users do not share one rate-limit identity.
SaaSyBase ships with six preconfigured tiers:
| Tier | Window | Max requests | Use case |
|---|---|---|---|
| AUTH | 15 min | 20 | Login attempts and auth-sensitive endpoints |
| API_GENERAL | 15 min | 100 | Standard API endpoints |
| API_SENSITIVE | 15 min | 10 | Password changes, account deletion, other sensitive operations |
| CHECKOUT | 1 min | 5 | Checkout session creation |
| WEBHOOK | 1 min | 1000 | Incoming webhook deliveries |
| EXPORT | 1 min | 20 | Data export endpoints |
Note
{ limit, windowMs, message? }. Some endpoints also use helper wrappers like adminRateLimit when actor-aware throttling is needed.Logging & Audit Trail
Secure logger
Always use the logger from lib/logger.ts instead of console.log. The logger:
- Auto-redacts secrets, tokens, passwords, and API keys from log output
- Persists warnings and errors to the database for admin review
- Provides structured
info,warn,error, anddebuglevels
import { Logger } from '@/lib/logger';
Logger.info('User signed up', { userId: user.id });
Logger.warn('Unusual login pattern', { ip, attempts });
Logger.error('Payment webhook failed', { error, providerId });Admin action log
All admin operations are logged to the AdminActionLog table with: the acting admin, the action performed, the target entity, a before/after snapshot, and the originating IP. These entries are viewable in the admin panel under the Activity section.
Account suspensions
SaaSyBase includes application-level suspension controls for both users and organization workspaces. These controls are enforced in the app layer, so the behavior stays consistent across Clerk, Better Auth, and NextAuth deployments.
| Target | Where admins manage it | What happens |
|---|---|---|
| User | /admin/users | Admins can apply temporary or permanent suspensions. Suspended users are blocked from sign-in and existing provider-backed session resolution treats them as signed out. |
| Organization workspace | /admin/organizations | Admins can suspend or restore a workspace without deleting its local row. Suspended workspaces expire pending invites and display a dashboard notice while suspended. |
Password policy applies to the app-managed credentials lanes. Clerk handles its own password requirements, while Better Auth and NextAuth use the local password-policy checks.
Session Activity Tracking
Active sessions are tracked with device detection and IP-based geolocation:
- Browser name and version
- Operating system
- Approximate geographic location, using
IPINFO_LITE_TOKENwhen configured andcountry.isas a fallback - Last active timestamp
Users see their session history inside /dashboard/profileunder the Security & Data tab.
For the built-in credentials sign-in route, the session cookie is also set with HttpOnly,SameSite=Lax, and the Secure flag on HTTPS deployments.
Geolocation is best-effort. If neither provider returns a result, the session remains valid and the location field is simply left blank.
Moderator Roles
Moderators have limited admin access — they can manage specific sections without full admin privileges. The system is configurable through the admin panel:
- Assign the MODERATOR role to any user
- Configure which admin sections each moderator can access
- Moderator activity is logged alongside admin actions
- Sections like billing, system settings, and moderator management are admin-only by default
Tip
Maintenance Mode
Toggle maintenance mode from the admin panel. When active:
- All public pages redirect to
/maintenanceand show the branded maintenance screen - API routes return
503 Service Unavailableas JSON - Admin routes are exempt — admins can still access the admin panel
- Auth routes, webhook routes, cron, health checks, and
/access-deniedremain accessible
The feature is DB-backed and controlled via admin settings rather than an environment variable or restart-only flag. Enforcement happens in middleware.
Because the setting lives in the shared database, changes take effect across running instances without a rebuild. The important prerequisite is that every instance points at the same database.
Password Policy
The built-in password policy enforces:
- Minimum 8 characters
- At least one uppercase letter, one lowercase letter, and one number
- Configurable requirements via
lib/password-policy.ts
Note
Input Validation
All API input is validated with Zod schemas defined in lib/validation.ts. Never trust raw request bodies:
import { someSchema } from '@/lib/validation';
export async function POST(req: Request) {
const body = await req.json();
const parsed = someSchema.parse(body);
// parsed is now typed and validated
}Zod validation errors are automatically caught by handleApiError() and returned as 400 Bad Request with field-level error messages.

