Zachary
Skip to main content

Zachary

Security Fundamentals for Web Apps

Web app security fundamentals concept cover

Security work is most effective when it becomes normal engineering discipline instead of a once in a while panic response. I do not think about security as a separate decorative layer that gets added near launch. I think about it as a series of boundaries, defaults, and habits that reduce the chance of avoidable mistakes. Most teams do not need more fear. They need clearer patterns.

From my experience building products in Portmore, I have learned that security is not about perfection. It is about reducing the attack surface systematically and making the most common vulnerabilities hard to introduce by accident. This article covers practical code patterns and configurations I use in every project.

Security starts with trust boundaries

Every application has trust boundaries whether the team names them or not. The browser is not the server. The authenticated user is not automatically authorized for every action. A form field is not trustworthy just because the UI constrained it. A third party service is not reliable just because its SDK is popular. Once you see the boundaries clearly, better decisions follow.

A lot of security bugs are really boundary bugs. Data crossed a line without being checked properly, constrained properly, or limited properly. The frontend can validate for UX, but the server must validate for security. Any input that reaches the server without server side validation is a potential vulnerability, regardless of what the frontend enforces.

Validation is one of the highest leverage habits

I like strong validation because it improves correctness and security at the same time. Inputs should be validated where they enter the system, shaped into expected formats, and rejected clearly when they do not belong. That applies to forms, query strings, API bodies, webhook payloads, and any external data crossing into application logic.

Validation is not about distrusting users personally. It is about refusing to let undefined behavior wander deeper into the system. A well validated API endpoint is harder to exploit, easier to debug, and produces clearer error messages for legitimate users who make typos.

CSP header configuration

Content Security Policy headers tell the browser which sources of content are allowed to load. A strong CSP is one of the most effective defenses against XSS attacks because even if an attacker manages to inject a script tag, the browser will refuse to execute it if the source is not in the policy.

Here is a CSP configuration I use as a starting point in Express applications:

const helmet = require('helmet');

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'", "https://api.myapp.com"],
      fontSrc: ["'self'", "https://fonts.gstatic.com"],
      objectSrc: ["'none'"],
      frameAncestors: ["'none'"],
      baseUri: ["'self'"],
      formAction: ["'self'"]
    }
  }
}));

The key principle is to start restrictive and widen only as needed. defaultSrc: ["'self'"] means nothing loads unless explicitly allowed. objectSrc: ["'none'"] blocks Flash and other plugin content. frameAncestors: ["'none'"] prevents your site from being embedded in iframes, which protects against clickjacking.

If you use inline styles (common with CSS in JS libraries), you may need 'unsafe-inline' for styleSrc, but try to avoid 'unsafe-inline' for scriptSrc. If you absolutely must allow inline scripts, use nonce based CSP instead of blanket 'unsafe-inline'.

Input sanitization in practice

Sanitization is about cleaning input before it is stored or rendered, particularly to prevent XSS. Here is a practical sanitization utility I use for user generated content that will be rendered as HTML:

const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');

const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

function sanitizeHTML(dirty) {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['href', 'target', 'rel'],
    ALLOW_DATA_ATTR: false
  });
}

function sanitizePlainText(input) {
  if (typeof input !== 'string') return '';
  return input
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

The sanitizeHTML function uses DOMPurify to allow only a safe subset of HTML tags and attributes. The sanitizePlainText function escapes everything for contexts where no HTML should be rendered at all. Use the right one for the context: if you are storing rich text, sanitize HTML. If you are storing a name or email, escape everything.

Authentication and authorization are different jobs

Who are you

Authentication answers identity. Are you signed in. Are your credentials valid. Is your session or token recognized. That matters, but it is only half the picture. A valid JWT proves who the user is, but it does not prove what they are allowed to do.

What are you allowed to do

Authorization answers permission. Can this user read this record, edit this resource, trigger this workflow, or access this admin surface. Teams get into trouble when they treat authentication as if it automatically solved authorization. It did not.

I try to make authorization checks explicit around sensitive actions so access logic does not become accidental. Every API endpoint that modifies data should check not just "is this user logged in" but "does this user have permission to perform this specific action on this specific resource."

CSRF protection with tokens

Cross site request forgery attacks trick the browser into making authenticated requests on behalf of a logged in user. CSRF tokens prevent this by requiring a secret token that the attacker cannot obtain:

const csrf = require('csurf');

const csrfProtection = csrf({
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict'
  }
});

app.get('/form', csrfProtection, (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

app.post('/submit', csrfProtection, (req, res) => {
  // token is validated automatically by the middleware
  res.json({ success: true });
});

The frontend fetches the CSRF token from the GET endpoint and includes it in the POST request header or body. The middleware validates it automatically. If the token is missing or invalid, the request is rejected.

Modern applications using SameSite=Strict or SameSite=Lax cookies get significant CSRF protection from the cookie policy itself, but explicit CSRF tokens add a second layer of defense.

httpOnly cookies and session security

Session cookies should always use httpOnly to prevent JavaScript from reading them, secure to require HTTPS, and sameSite to control cross origin behavior:

app.use(session({
  secret: process.env.SESSION_SECRET,
  name: 'sid',
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 24 * 60 * 60 * 1000
  },
  resave: false,
  saveUninitialized: false,
  store: new RedisStore({ client: redisClient })
}));

Using a Redis store for sessions means sessions survive server restarts and can be shared across multiple server instances. The saveUninitialized: false option prevents empty sessions from being created, which reduces storage waste and avoids setting cookies on unauthenticated visitors.

Rate limiting

Rate limiting protects against brute force attacks, credential stuffing, and API abuse. I apply it at the route level with stricter limits on authentication endpoints:

const rateLimit = require('express-rate-limit');

const generalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later' }
});

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many login attempts, please try again later' }
});

app.use('/api/', generalLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);

The auth limiter allows only 5 attempts per 15 minute window, which makes brute force attacks impractical while still being generous enough for legitimate users who mistype their password.

Least privilege is a practical design rule

The principle of least privilege sounds grand, but in day to day work it simply means giving systems and users only the access they actually need. That includes API keys, database roles, internal tools, and feature permissions. Overbroad access turns small mistakes into bigger incidents.

I like least privilege because it reduces blast radius. You may not prevent every bug, but you can prevent many bugs from becoming disasters. A database user that only has SELECT and INSERT permissions on specific tables cannot accidentally DROP a table even if the application code has a bug.

Dependency auditing

Third party dependencies are one of the largest attack surfaces in modern applications. I run audits regularly and treat high severity vulnerabilities as blockers:

npm audit
npm audit fix

# For a more detailed view:
npx audit-ci --config audit-ci.json

I also use Dependabot or Renovate for automated dependency update PRs. The key is not to blindly auto merge updates, but to review them, especially major version bumps that might change behavior.

Beyond automated tools, I periodically review the dependency tree for packages that are unmaintained, have very low download counts, or were recently transferred to new maintainers. Supply chain attacks are real, and awareness is the first line of defense.

Secrets management

Secrets should never appear in source code, commit history, or client side bundles. My baseline rules:

  • Use .env files locally, but never commit them. Add .env to .gitignore from the start of the project.
  • Use environment variables in CI/CD and production. Services like Vercel, Railway, and AWS Secrets Manager all support injecting secrets at deploy time.
  • Rotate secrets when team members leave or when a leak is suspected.
  • Use different secrets for different environments. Your staging database password should not be the same as production.
  • Never log secret values. Structured logging should explicitly exclude authorization headers, API keys, and session tokens.

I also make it a habit to search the codebase periodically for hardcoded strings that look like API keys or tokens. A simple grep for patterns like sk_live_ or AKIA can catch secrets that were accidentally committed.

Security headers checklist

Beyond CSP, there are several other security headers I set on every project. Here is the complete set I use with helmet:

app.use(helmet({
  contentSecurityPolicy: { /* ... see above ... */ },
  crossOriginEmbedderPolicy: true,
  crossOriginOpenerPolicy: true,
  crossOriginResourcePolicy: { policy: 'same-site' },
  dnsPrefetchControl: true,
  frameguard: { action: 'deny' },
  hidePoweredBy: true,
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
  ieNoOpen: true,
  noSniff: true,
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  xssFilter: true
}));

Quick reference for what each one does:

  • HSTS: Forces HTTPS for the specified duration. The preload flag submits your domain for browser HSTS preload lists.
  • X-Frame-Options (frameguard): Prevents clickjacking by blocking iframe embedding.
  • X-Content-Type-Options (noSniff): Prevents browsers from guessing content types, which can lead to XSS.
  • Referrer-Policy: Controls how much referrer information is sent with requests.
  • X-Powered-By (hidePoweredBy): Removes the X-Powered-By: Express header that reveals your tech stack.

Safer defaults beat heroic cleanup

A lot of security posture comes down to defaults. Secure cookie settings. Sensible rate limits. No secrets in the client. Stronger password requirements where relevant. Output escaping where untrusted content appears. Conservative file handling. Honest CORS configuration. These habits are not glamorous, but they compound.

I would rather ship a system with safer defaults than rely on perfect human memory during a hectic deadline. Every default that is secure by default is one fewer thing the team has to remember to configure correctly under pressure.

Security should support velocity, not fight it

The goal is not to create a codebase that nobody can move in. The goal is to build enough discipline that teams can move quickly without stepping into obvious traps. Good security patterns reduce rework because they clarify the rules of the system.

If you want help building safer product foundations, review my custom software service, pair this with the API design article, or contact me for practical web application work that respects both security and delivery.

Need help building something similar?

I design and build premium websites, product experiences, and custom software with a strong focus on clarity, performance, and real business outcomes.