Secure Feedback Collection: A Defense-in-Depth Approach to Customer Data
Learn how to protect customer feedback with a 5-layer security architecture. Covers OWASP best practices, CSRF protection, rate limiting, input validation, and authentication for SaaS feedback widgets.

Summary
Customer feedback is among the most sensitive data your SaaS application handles. Users share personal details, business pain points, and candid opinions—information that demands rigorous protection. This article explores how a defense-in-depth security architecture protects feedback data at every layer, from network infrastructure to application logic.
Why Feedback Data Deserves Special Protection
When users submit feedback, they often reveal:
- Personal Identifiable Information (PII): Names, email addresses, company details
- Business Intelligence: Workflow bottlenecks, feature requests, competitive insights
- Emotional Context: Frustrations, praise, and unfiltered opinions
- Technical Details: Bug reports, system configurations, integration needs
A breach of feedback data exposes not just your users—it exposes your entire product roadmap to competitors and your users' trust to erosion.
The OWASP Perspective
The OWASP Top 10 identifies the most critical web application security risks. Feedback collection systems face unique exposure to:
- Injection Attacks (A03:2021): User-submitted text is a prime vector for XSS and injection
- Broken Access Control (A01:2021): Ensuring users only access their own feedback
- Security Misconfiguration (A05:2021): Widget embeds on third-party sites increase attack surface
- Server-Side Request Forgery (A10:2021): Webhook callbacks require careful validation
The 5-Layer Defense-in-Depth Architecture
Defense-in-depth means no single security control stands alone. If one layer fails, others continue protecting your data. Here's how each layer works in a secure feedback collection system.
Layer 1: Infrastructure and Security Headers
The first line of defense operates at the HTTP level, protecting against common web vulnerabilities before they reach your application logic.
Content Security Policy (CSP)
CSP prevents XSS attacks by controlling which scripts can execute:
// next.config.ts - Security headers configuration
const securityHeaders = [
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"connect-src 'self' https://*.convex.cloud wss://*.convex.cloud",
"frame-ancestors 'none'",
].join("; "),
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains; preload",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
];
Why These Headers Matter
| Header | Protection |
|---|---|
Content-Security-Policy | Prevents XSS by restricting script sources |
X-Frame-Options: DENY | Blocks clickjacking attacks |
X-Content-Type-Options: nosniff | Prevents MIME type sniffing attacks |
Strict-Transport-Security | Forces HTTPS connections |
Referrer-Policy | Controls information leakage in referrer headers |
Layer 2: Rate Limiting
Rate limiting prevents brute force attacks, credential stuffing, and resource exhaustion. For feedback widgets, it also prevents spam submissions that could overwhelm your support team.
// lib/rateLimit.ts - In-memory rate limiter
interface RateLimitEntry {
count: number;
resetTime: number;
}
const store = new Map<string, RateLimitEntry>();
export function rateLimit(
identifier: string,
limit: number = 5,
windowMs: number = 60000
): { success: boolean; remaining: number; resetIn: number } {
const now = Date.now();
const entry = store.get(identifier);
// Clean up expired entries
if (entry && now > entry.resetTime) {
store.delete(identifier);
}
const current = store.get(identifier);
if (!current) {
store.set(identifier, { count: 1, resetTime: now + windowMs });
return { success: true, remaining: limit - 1, resetIn: windowMs };
}
if (current.count >= limit) {
return {
success: false,
remaining: 0,
resetIn: current.resetTime - now,
};
}
current.count++;
return {
success: true,
remaining: limit - current.count,
resetIn: current.resetTime - now,
};
}
Applying Rate Limits to Feedback Endpoints
// api/feedback/route.ts
import { rateLimit } from "@/lib/rateLimit";
import { headers } from "next/headers";
export async function POST(request: Request) {
const headersList = await headers();
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "unknown";
// 5 feedback submissions per minute per IP
const rateLimitResult = rateLimit(`feedback:${ip}`, 5, 60000);
if (!rateLimitResult.success) {
return new Response(
JSON.stringify({
error: "Too many requests",
retryAfter: Math.ceil(rateLimitResult.resetIn / 1000),
}),
{
status: 429,
headers: {
"Retry-After": String(Math.ceil(rateLimitResult.resetIn / 1000)),
"Content-Type": "application/json",
},
}
);
}
// Process feedback submission...
}
Layer 3: CSRF Protection
Cross-Site Request Forgery (CSRF) attacks trick authenticated users into submitting malicious requests. For feedback widgets embedded on third-party sites, CSRF protection is critical.
// lib/csrf.ts - HMAC-SHA256 token generation and validation
import { createHmac, randomBytes } from "crypto";
const CSRF_SECRET = process.env.CSRF_SECRET!;
const TOKEN_EXPIRY_MS = 3600000; // 1 hour
interface CsrfTokenData {
sessionId: string;
timestamp: number;
nonce: string;
}
export function generateCsrfToken(sessionId: string): string {
const data: CsrfTokenData = {
sessionId,
timestamp: Date.now(),
nonce: randomBytes(16).toString("hex"),
};
const payload = JSON.stringify(data);
const signature = createHmac("sha256", CSRF_SECRET)
.update(payload)
.digest("hex");
return Buffer.from(`${payload}:${signature}`).toString("base64");
}
export function validateCsrfToken(
token: string,
sessionId: string
): { valid: boolean; error?: string } {
try {
const decoded = Buffer.from(token, "base64").toString("utf-8");
const [payload, signature] = decoded.split(":");
// Verify signature
const expectedSignature = createHmac("sha256", CSRF_SECRET)
.update(payload)
.digest("hex");
if (signature !== expectedSignature) {
return { valid: false, error: "Invalid signature" };
}
const data: CsrfTokenData = JSON.parse(payload);
// Verify session binding
if (data.sessionId !== sessionId) {
return { valid: false, error: "Session mismatch" };
}
// Verify expiry
if (Date.now() - data.timestamp > TOKEN_EXPIRY_MS) {
return { valid: false, error: "Token expired" };
}
return { valid: true };
} catch {
return { valid: false, error: "Malformed token" };
}
}
Token Delivery Best Practices
CSRF tokens should be delivered via HTTP-only, SameSite=Strict cookies:
// Secure cookie settings for CSRF tokens
const csrfCookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict" as const,
path: "/",
maxAge: 3600, // 1 hour
};
Layer 4: Input Validation and XSS Prevention
Every piece of user input is a potential attack vector. Defense-in-depth requires validation at multiple points: client-side for UX, server-side for security.
// lib/validation.ts - Zod schemas with XSS sanitization
import { z } from "zod";
// XSS sanitization function
function sanitizeForXss(input: string): string {
return input
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// Custom Zod transformer for sanitized strings
const sanitizedString = z.string().transform(sanitizeForXss);
// Feedback submission schema
export const FeedbackSchema = z.object({
// Rating: 1-5 stars
rating: z.number().int().min(1).max(5),
// Feedback text: required, sanitized, length-constrained
message: sanitizedString.refine(
(val) => val.length >= 10 && val.length <= 5000,
{ message: "Feedback must be between 10 and 5000 characters" }
),
// Optional email: validated format
email: z.string().email().optional().or(z.literal("")),
// Category: enumerated values only
category: z.enum(["bug", "feature", "general", "praise"]),
// Metadata: structured, validated
metadata: z
.object({
page: z.string().max(500).optional(),
userAgent: z.string().max(500).optional(),
timestamp: z.string().datetime().optional(),
})
.optional(),
});
export type FeedbackInput = z.infer<typeof FeedbackSchema>;
Server-Side Validation in Action
// api/feedback/route.ts
import { FeedbackSchema } from "@/lib/validation";
export async function POST(request: Request) {
// ... rate limiting check ...
const body = await request.json();
// Validate with Zod - throws on failure
const parseResult = FeedbackSchema.safeParse(body);
if (!parseResult.success) {
return new Response(
JSON.stringify({
error: "Validation failed",
details: parseResult.error.flatten().fieldErrors,
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
const validatedData = parseResult.data;
// Data is now type-safe AND sanitized
// Proceed with storage...
}
Layer 5: Authentication and Authorization
The final layer ensures that only authorized users can access feedback data, and that users can only access their own data.
// Convex function with authentication and ownership validation
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// Query: Get feedback for a project (owner only)
export const getProjectFeedback = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
// Verify authentication
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Authentication required");
}
// Verify project ownership
const project = await ctx.db.get(args.projectId);
if (!project) {
throw new Error("Project not found");
}
if (project.userId !== identity.subject) {
throw new Error("Access denied: Not project owner");
}
// Query feedback with ownership verified
return await ctx.db
.query("feedback")
.withIndex("by_project", (q) => q.eq("projectId", args.projectId))
.order("desc")
.collect();
},
});
// Mutation: Delete feedback (owner only)
export const deleteFeedback = mutation({
args: { feedbackId: v.id("feedback") },
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Authentication required");
}
const feedback = await ctx.db.get(args.feedbackId);
if (!feedback) {
throw new Error("Feedback not found");
}
// Get the project to verify ownership
const project = await ctx.db.get(feedback.projectId);
if (!project || project.userId !== identity.subject) {
throw new Error("Access denied");
}
await ctx.db.delete(args.feedbackId);
return { success: true };
},
});
JWT-Based Widget Authorization
For embedded widgets, JWT tokens provide secure, stateless authorization:
// Widget endpoint with JWT validation
export async function widgetFeedbackHandler(
request: Request,
projectId: string,
jwt: string
) {
// Fetch project to get its secret key
const project = await getProject(projectId);
if (!project) {
return new Response("Project not found", { status: 404 });
}
// Verify JWT with project-specific secret
// This IS the authorization check:
// - Valid JWT signature proves caller has the project's secret
// - Each project has a unique secret key
// - Attacker cannot forge JWT without the secret
const payload = verifyJwt(jwt, project.secretKey);
if (!payload) {
return new Response("Invalid token", { status: 401 });
}
// Process authenticated widget request...
}
Security Metrics and Monitoring
A defense-in-depth architecture is only as good as your ability to monitor it. Track these key metrics:
OWASP Score Tracking
Maintain an internal security score based on OWASP controls:
| Category | Weight | Implementation |
|---|---|---|
| Authentication | 20% | Clerk MFA, session management |
| Authorization | 20% | Ownership validation, RBAC |
| Input Validation | 15% | Zod schemas, XSS sanitization |
| CSRF Protection | 15% | HMAC tokens, SameSite cookies |
| Rate Limiting | 10% | Per-IP throttling |
| Security Headers | 10% | CSP, HSTS, X-Frame-Options |
| Encryption | 10% | TLS 1.3, encrypted storage |
Target Score: 90/100 or higher
Real-Time Monitoring
// Structured logging for security events
function logSecurityEvent(event: {
type: "rate_limit" | "csrf_failure" | "auth_failure" | "validation_error";
ip: string;
details: Record<string, unknown>;
}) {
console.log(
JSON.stringify({
timestamp: new Date().toISOString(),
level: "SECURITY",
...event,
})
);
// Alert on threshold breaches
if (event.type === "csrf_failure") {
// Potential CSRF attack - alert security team
}
}
Implementation Checklist
Infrastructure Layer
- TLS/SSL certificate configured and auto-renewing
- Security headers in place (CSP, HSTS, X-Frame-Options)
- HTTPS redirect enforced
- Secure cookie attributes configured
Rate Limiting Layer
- IP-based rate limiting on all public endpoints
- Differentiated limits for authenticated vs anonymous users
- Automatic cleanup of rate limit entries
- Monitoring for rate limit threshold breaches
CSRF Protection Layer
- HMAC-SHA256 token generation
- Token bound to user session
- Token expiration enforced
- HTTP-only, SameSite=Strict cookies
Input Validation Layer
- Zod schemas for all user input
- XSS sanitization on text fields
- Format validation (email, URLs, etc.)
- Length constraints on all string fields
Authentication Layer
- Clerk (or equivalent) for identity management
- MFA enabled for admin accounts
- Session timeout configured
- Ownership validation on all data access
Key Takeaways
-
Defense-in-depth is non-negotiable: No single security control is sufficient. Layer your defenses so failures in one layer are caught by others.
-
Feedback data is high-value: Users share PII, business intelligence, and emotional context. Protect it accordingly.
-
Validate at every boundary: Client-side validation for UX, server-side validation for security. Never trust the client.
-
Monitor and measure: Track security metrics, log security events, and alert on anomalies.
-
Widget security requires special attention: Embedded widgets on third-party sites face unique attack vectors. JWT-based authorization with project-specific secrets provides secure, stateless authentication.
Building a secure feedback collection system isn't just about compliance—it's about earning and maintaining user trust. When users know their feedback is protected, they share more openly, providing the insights you need to build better products.
Interested in implementing secure feedback collection for your SaaS? Get started with UserVibesOS and leverage our 5-layer security architecture out of the box.
Related Articles
Behavioral Anomaly Detection: Security Signals in User Feedback
Detect account takeovers, fraud attempts, and security incidents through unusual patterns in user behavior and feedback. Turn feedback into a security sensor.
Security Monitoring for SaaS: Detecting Threats Before Breaches
Learn proactive security monitoring for SaaS applications. Covers threat detection signals, anomaly patterns, and AI-powered alerting strategies.
JWT Authentication for Embedded Widgets: When Signatures Replace Ownership Checks
Learn why JWT signature verification IS authorization for embedded widgets. Understand how cryptographic proof through project-specific secret keys provides multi-tenant isolation without explicit ownership database checks.
Written by Secure Vibe Team
Published on January 9, 2026