How I Built a Security-First SaaS Boilerplate with 100% Test Coverage
Source: Dev.to
We’ll add security later.
If you’ve ever said this, you’re not alone. I’ve been there too. But after seeing countless data breaches, emergency security patches at 2 AM, and the $4.45 million average cost of a breach, I decided to take a different approach.
This is the story of how I built ShipSecure — a Next.js boilerplate where security isn’t an afterthought, it’s the foundation.
The Problem: Security as a TODO Item
Most developers approach security like this:
- ✅ Build landing page
- ✅ Add authentication
- ✅ Integrate payments
- ⬜ Security (we’ll get to it later)
Sound familiar? The issue is that later often means after the first incident. By then you’ve already:
- Lost customer trust
- Faced potential regulatory fines (GDPR, CCPA, SOC 2)
- Spent weeks retrofitting security into an architecture that wasn’t designed for it
The Solution: Security‑First Development
Instead of treating security as a feature, I treated it as architecture. Every line of code was written with security in mind, and every security feature is covered by tests.
Let me walk you through the key concepts.
1. Security Headers – Your First Line of Defense
Most developers know about Content‑Security‑Policy, but how many actually implement it correctly? It’s not just about adding one header; you need a complete security‑header strategy.
| Header | What It Prevents |
|---|---|
Content‑Security‑Policy | XSS attacks, code injection |
Strict‑Transport‑Security | Protocol‑downgrade attacks |
X‑Content‑Type‑Options | MIME‑sniffing vulnerabilities |
X‑Frame‑Options | Clickjacking attacks |
Referrer‑Policy | Information leakage |
Permissions‑Policy | Unauthorized browser‑feature access |
The tricky part? Getting these headers to work together without breaking your app. CSP alone has dozens of directives that need careful configuration—one wrong setting and your OAuth flow breaks, analytics stop, or styles don’t load.
My approach: a centralized getSecurityHeaders() function that returns a properly configured header object, applied consistently across all routes via middleware.
// lib/securityHeaders.ts
export function getSecurityHeaders() {
return {
"Content-Security-Policy":
"default-src 'self'; script-src 'self' https://trusted.cdn.com",
"Strict-Transport-Security":
"max-age=63072000; includeSubDomains; preload",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Permissions-Policy": "geolocation=(), microphone=()",
};
}
2. Rate Limiting – The Art of Saying “Slow Down”
Without rate limiting, your API is an open invitation for:
- 🔓 Brute‑force attacks on login
- 💥 DDoS attempts
- 🎭 Credential stuffing
Production vs. Development
Production needs distributed rate limiting (e.g., Upstash Redis) so limits work across serverless instances. Development shouldn’t require a Redis server.
My solution: a hybrid architecture with automatic fallback.
// lib/rateLimiter.ts
import { Redis } from "@upstash/redis";
let store: RateLimitStore;
if (process.env.UPSTASH_REDIS_URL) {
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL,
token: process.env.UPSTASH_REDIS_TOKEN,
});
store = new RedisStore(redis);
} else {
store = new MemoryStore(); // simple in‑memory map
}
// Export a rate‑limit middleware that uses a sliding‑window algorithm
export const rateLimiter = createRateLimiter({
store,
window: 60, // seconds
limit: 100, // requests per window
algorithm: "sliding",
});
Why sliding‑window?
With a fixed window an attacker can “burst” at the boundary (e.g., 10 requests at :59 and another 10 at :01). Sliding‑window eliminates that edge case.
3. Test‑Driven Security – The Game Changer
Every security feature is backed by tests.
Why it matters
- Tests prove claims. Anyone can say “we use CSP.” Tests verify it.
- Tests prevent regression. An intern can’t accidentally remove a header.
- Tests document behavior. Security requirements become executable specifications.
Three‑layer testing strategy
Layer 1: Unit Tests (Vitest)
• Individual security functions
• Validation logic
• Edge cases
Layer 2: Integration Tests
• Middleware behavior
• Auth flows
• API protection
Layer 3: E2E Tests (Playwright)
• Real‑browser behavior
• Header verification
• User‑journey security
The result? 75+ tests covering every security mechanism. CI/CD runs them on every PR—no security feature ships without coverage.
4. Input Validation – Where Most Breaches Start
SQL injection, XSS, command injection—all stem from unvalidated input.
Typical (vulnerable) code
// ❌ This is asking for trouble
const { email, name } = await req.json();
await db.users.create({ email, name });
No validation, no type checking, no protection.
Security‑first approach
- Define schemas for every input.
- Validate at the boundary (API routes, form submissions).
- Fail fast with clear error messages.
- Never trust—even authenticated users can send malicious data.
I use Zod for its compile‑time type safety and runtime validation, but the tool is less important than the discipline of validating everything, everywhere.
// lib/schemas.ts
import { z } from "zod";
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
});
// pages/api/users.ts
import { createUserSchema } from "@/lib/schemas";
export async function POST(req: Request) {
const data = await req.json();
const parsed = createUserSchema.safeParse(data);
if (!parsed.success) {
return new Response(JSON.stringify(parsed.error), { status: 400 });
}
await db.users.create(parsed.data);
return new Response(null, { status: 201 });
}
5. Authentication Done Right
Authentication is where most security vulnerabilities live: password storage, session management, CSRF protection, OAuth flows, etc.
My approach: use Auth.js v5 (formerly NextAuth) because it gives you:
- ✅ HttpOnly, Secure cookies by default
- ✅ Built‑in CSRF protection
- ✅ Battle‑tested OAuth implementations
- ✅ Reliable session management
Key insight: authentication isn’t just login/logout—it’s also about:
- How sessions are stored and validated
- Cookie configuration (SameSite, Secure, HttpOnly)
- Token handling and rotation
- Proper logout that truly invalidates sessions
Auth.js handles all of these out of the box, reducing the chance of a critical mistake.
The Hard‑Won Lessons
After building this, here’s what I wish I’d known earlier:
1. Security is 10× cheaper when designed in
Retrofitting security into an existing codebase is painful. Every decision you made without security in mind later becomes a costly fix.
(…the rest of the lessons would follow in the original content.)
2. Tests are your best security documentation
When someone asks “how do you prevent X?”, point them to the test file. It’s proof, not promises.
3. Developer experience matters
If security measures are annoying, developers find workarounds.
- The in‑memory fallback for rate limiting? That’s about DX.
- The centralized header function? That’s about DX.
Security should be invisible to developers using your codebase.
4. Automate everything
Security tests in CI/CD mean no one can accidentally (or intentionally) ship insecure code. It’s not about trust — it’s about systems.
The Bottom Line
Here’s the reality:
- 60 % of startups shut down within 6 months of a security breach
- 73 % of enterprise customers require security certifications
- SOC 2 compliance can shorten sales cycles by 40 %
Security isn’t a feature — it’s a competitive advantage.
Want the Full Implementation?
I’ve packaged everything I learned into ShipSecure — a Next.js 15 boilerplate that’s secure from day one:
- 🛡️ All 7 security headers pre‑configured and working
- ⚡ Rate limiting with Redis + in‑memory fallback
- 🔐 Auth.js v5 with secure defaults
- ✅ 75+ tests for 100 % security coverage
- 📦 Stripe integration included
- 📚 Complete documentation
- 🔄 Lifetime updates
One‑time purchase. Skip the security research. Start building.
You build. We secure.
Have questions about security in Next.js? Drop a comment — I read every one.