Engineering Note
Engineering

Security Mistakes I Avoid in Full-Stack Apps

Small Decisions That Protect Real Users

9 min read
IntermediateEngineering

Introduction

Security is not one feature. It is a set of small decisions across authentication, validation, protected routes, environment variables, APIs, database access, logging, and deployment workflows.

In full-stack applications, security mistakes usually happen when developers trust the frontend too much, expose secrets accidentally, skip backend validation, or forget abuse protection for sensitive endpoints.

A secure application is built by assuming that every client request can be modified, every exposed endpoint can be abused, and every sensitive action must be verified on the server.

This note focuses on practical security mistakes I avoid in full-stack apps, especially the decisions that affect reliability, maintainability, user trust, data protection, and production safety.

The Problem

Security issues rarely come from one obvious mistake. They usually appear when small weak points stack together across frontend logic, backend APIs, database queries, authentication flows, environment configuration, and deployment settings.

Common Failures

  • API keys accidentally get exposed to the client bundle
  • Protected routes are only hidden visually on the frontend
  • User input reaches the database without backend validation
  • CORS, rate limiting, and safe error handling are ignored
  • Users can access records they do not own
  • Logs accidentally store tokens, passwords, or private user data

Engineering Impact

  • User data becomes easier to expose or misuse
  • APIs become vulnerable to spam, brute force, and abuse
  • Deployment mistakes can leak sensitive configuration
  • Debugging becomes harder when errors reveal too much or too little
  • Authorization bugs create serious trust issues
  • Security fixes become harder when rules are scattered across the codebase

The challenge is to build security into every layer instead of treating it as something that can be added only at the end.

System Design / Approach

The approach is to assume that the frontend can be bypassed and the backend must enforce the real rules. Secrets, validation, authorization, ownership, rate limits, and logging should be handled deliberately.

Client Request
    ↓
Authentication Check
    ↓
Authorization and Ownership Check
    ↓
Input Validation
    ↓
Rate Limit and Abuse Protection
    ↓
Business Logic
    ↓
Safe Database Access
    ↓
Sanitized Response and Logs

1. Keep Secrets on the Server

Database URLs, private API keys, signing secrets, payment secrets, and service credentials should never be exposed to client-side code.

2. Validate Every Backend Request

Frontend validation improves user experience, but backend validation is what protects the database, business logic, and API contracts.

3. Protect Sensitive Routes Properly

Protected APIs should check authentication, authorization, ownership, and request limits before performing sensitive actions.

4. Log Safely Without Leaking Secrets

Logs should help debugging, but they should never expose passwords, tokens, private keys, cookies, authorization headers, or unnecessary personal data.

Implementation

Step 1: Protect Environment Variables

Secrets should never be exposed in client-side bundles. Public variables and server-only variables should be separated clearly.

.env
DATABASE_URL=postgresql://...
API_SECRET=server-only-value
JWT_SECRET=server-only-secret
PAYMENT_SECRET_KEY=server-only-payment-secret

NEXT_PUBLIC_APP_URL=https://example.com

Server-only secrets protect databases, external services, authentication tokens, payment credentials, and private infrastructure.

Step 2: Validate Environment Configuration

Missing or incorrect environment variables can break authentication, database connections, payment flows, or deployment behavior. The app should fail early when required configuration is missing.

env-check.ts
const requiredServerEnv = [
  "DATABASE_URL",
  "JWT_SECRET",
  "API_SECRET",
];

for (const key of requiredServerEnv) {
  if (!process.env[key]) {
    throw new Error(`Missing required server environment variable: ${key}`);
  }
}

Environment validation catches deployment mistakes before they become runtime security issues.

Step 3: Validate Backend Input

Backend validation is required even if frontend validation already exists. Every API should reject invalid input before it reaches business logic or the database.

validation.ts
const createTaskSchema = z.object({
  title: z.string().min(2).max(120),
  description: z.string().max(1000).optional(),
  priority: z.enum(["low", "medium", "high"]),
});

const payload = createTaskSchema.parse(req.body);

await service.createRecord({
  userId: currentUser.id,
  data: payload,
});

Frontend validation helps users, but backend validation protects the system from malformed, unsafe, or unexpected data.

Step 4: Check Authentication on Protected APIs

Protected pages are not enough. Every sensitive API route should verify the current user before reading or changing private data.

auth-check.ts
export async function requireUser(request: Request) {
  const session = await getSession(request);

  if (!session?.user) {
    throw new Error("Unauthorized");
  }

  return session.user;
}

Authentication checks make sure the backend does not trust visual route protection alone.

Step 5: Enforce Authorization and Ownership

Authentication proves who the user is. Authorization proves what the user is allowed to do. Ownership checks prevent users from accessing records that belong to someone else.

ownership-check.ts
const task = await db.task.findUnique({
  where: {
    id: taskId,
  },
});

if (!task || task.userId !== currentUser.id) {
  return Response.json(
    {
      success: false,
      error: {
        code: "FORBIDDEN",
        message: "You do not have access to this resource.",
      },
    },
    { status: 403 }
  );
}

Ownership checks prevent broken access control, one of the most damaging security mistakes in full-stack apps.

Step 6: Add Rate Limiting

Sensitive endpoints should be protected from repeated abuse. Login, password reset, contact forms, AI generation, payment-related routes, and public APIs need request limits.

rate-limit.ts
if (attempts > limit) {
  return Response.json(
    {
      success: false,
      error: {
        code: "RATE_LIMITED",
        message: "Too many requests. Please try again later.",
      },
    },
    { status: 429 }
  );
}

Rate limiting reduces brute force attempts, spam, scraping, and accidental overload on expensive endpoints.

Step 7: Handle Errors Safely

API errors should be useful to users and developers without exposing stack traces, database details, tokens, or internal infrastructure information.

safe-error.ts
try {
  return await handler(request);
} catch (error) {
  logger.error("API request failed", {
    route: request.url,
    errorName: error.name,
  });

  return Response.json(
    {
      success: false,
      error: {
        code: "INTERNAL_ERROR",
        message: "Something went wrong. Please try again.",
      },
    },
    { status: 500 }
  );
}

Safe error handling avoids leaking internal details while still giving users a clear response.

Step 8: Configure CORS Carefully

CORS should not be treated as a random browser error fix. It defines which origins can access your APIs from browsers and should be configured intentionally.

cors.ts
const allowedOrigins = [
  "https://example.com",
  "https://admin.example.com",
];

if (!allowedOrigins.includes(origin)) {
  return Response.json(
    {
      success: false,
      error: {
        code: "CORS_BLOCKED",
      },
    },
    { status: 403 }
  );
}

Strict CORS rules reduce accidental exposure of APIs to untrusted browser origins.

Step 9: Store Passwords Safely

Passwords should never be stored as plain text. If the app manages passwords directly, they should be hashed using a strong password hashing algorithm before storage.

password-hash.ts
const passwordHash = await bcrypt.hash(password, 12);

await db.user.create({
  data: {
    email,
    passwordHash,
  },
});

Password hashing reduces damage if database records are ever exposed.

Step 10: Use Safe Cookies for Sessions

Session cookies should be configured carefully to reduce token exposure and cross-site abuse. Cookie settings should match the deployment environment.

session-cookie.ts
cookies().set("session", sessionToken, {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax",
  path: "/",
  maxAge: 60 * 60 * 24 * 7,
});

Secure cookie settings help protect session tokens from client-side access and common browser-based attacks.

Step 11: Redact Sensitive Logs

Logs should help debugging without becoming a second place where secrets are stored. Sensitive fields should be removed before logging request bodies, errors, or metadata.

log-redaction.ts
function redact(payload: Record<string, unknown>) {
  const blockedFields = [
    "password",
    "token",
    "accessToken",
    "refreshToken",
    "authorization",
    "cookie",
  ];

  return Object.fromEntries(
    Object.entries(payload).map(([key, value]) => [
      key,
      blockedFields.includes(key) ? "[REDACTED]" : value,
    ])
  );
}

Redacted logs preserve debugging context without exposing sensitive values.

Step 12: Add Security Headers

Security headers help browsers enforce safer behavior. They are not a replacement for secure code, but they reduce the impact of several common mistakes.

headers.ts
const securityHeaders = {
  "X-Content-Type-Options": "nosniff",
  "X-Frame-Options": "DENY",
  "Referrer-Policy": "strict-origin-when-cross-origin",
  "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
};

Security headers add browser-level protection around content, framing, referrers, and sensitive permissions.

Trade-offs

Approach Benefit Cost
Strict Validation Safer data and fewer unexpected backend states Requires more schema work and maintenance
Rate Limiting Protects APIs from brute force, spam, and repeated abuse Can affect valid users if limits are too strict
Server-Only Secrets Keeps private credentials away from client-side exposure Requires careful deployment and environment configuration
Authorization Checks Prevents users from accessing resources they do not own Requires consistent ownership and role logic
Safe Error Handling Avoids leaking internal system details Needs proper logging so debugging remains possible
Security Headers Adds browser-level protection Requires testing to avoid breaking legitimate behavior

Real-World Impact

Safer User Data

User data becomes safer because sensitive actions are protected with real backend checks, ownership validation, and controlled session handling.

Stronger APIs

APIs become harder to misuse because input validation, authorization, rate limits, and safe errors reduce common attack paths.

Safer Deployments

Deployment mistakes become less likely when secrets, environments, public configuration, and production settings are handled carefully.

What I Learned

  • Security is built through many small decisions across the full stack.
  • The frontend should improve experience, but the backend must enforce trust.
  • Secrets should stay server-side and should never be exposed to the client bundle.
  • Authentication is not enough. Authorization and ownership checks are also required.
  • Validation protects business logic from unsafe or unexpected input.
  • Rate limits protect expensive and sensitive endpoints from abuse.
  • Logs should support debugging without storing secrets or private data.

Conclusion

Security in full-stack apps is not handled by one library or one middleware. It comes from careful decisions across authentication, authorization, validation, secrets, APIs, database access, logging, and deployment.

A secure full-stack system keeps secrets on the server, validates backend input, protects sensitive routes, checks ownership, limits abuse, handles errors safely, redacts logs, and uses secure deployment practices.

The key lesson is simple: security should be part of every layer. The safest apps are built by assuming the client can be bypassed and the backend must enforce the real rules.

Key Takeaways

Security is built through many small decisions

Secrets must never be exposed to the frontend

Backend validation is mandatory

Protected UI is not the same as protected data

Rate limiting protects both product and infrastructure

Future Improvements

Add security headers

Improve CORS configuration

Add audit logs for sensitive actions

Use dependency scanning

Add stricter file upload validation