Back to Blog
Security

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.

Secure Vibe Team
11 min read
Secure Feedback Collection: A Defense-in-Depth Approach to Customer Data

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:

  1. Injection Attacks (A03:2021): User-submitted text is a prime vector for XSS and injection
  2. Broken Access Control (A01:2021): Ensuring users only access their own feedback
  3. Security Misconfiguration (A05:2021): Widget embeds on third-party sites increase attack surface
  4. 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

HeaderProtection
Content-Security-PolicyPrevents XSS by restricting script sources
X-Frame-Options: DENYBlocks clickjacking attacks
X-Content-Type-Options: nosniffPrevents MIME type sniffing attacks
Strict-Transport-SecurityForces HTTPS connections
Referrer-PolicyControls 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#x27;");
}

// 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:

CategoryWeightImplementation
Authentication20%Clerk MFA, session management
Authorization20%Ownership validation, RBAC
Input Validation15%Zod schemas, XSS sanitization
CSRF Protection15%HMAC tokens, SameSite cookies
Rate Limiting10%Per-IP throttling
Security Headers10%CSP, HSTS, X-Frame-Options
Encryption10%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

  1. Defense-in-depth is non-negotiable: No single security control is sufficient. Layer your defenses so failures in one layer are caught by others.

  2. Feedback data is high-value: Users share PII, business intelligence, and emotional context. Protect it accordingly.

  3. Validate at every boundary: Client-side validation for UX, server-side validation for security. Never trust the client.

  4. Monitor and measure: Track security metrics, log security events, and alert on anomalies.

  5. 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.

Share this article

Related Articles

Written by Secure Vibe Team

Published on January 9, 2026