Building Secure Web Authentication - Part 5: Security & Production

We've built a solid authentication system, but is it secure enough for production? In this final part, we'll harden our security, add social login with OAuth, and prepare for production deployment.

By the end, you'll have a battle-tested authentication system ready for real users.

What We're Covering

  • ✅ Common security vulnerabilities and how to prevent them
  • ✅ Social login with OAuth (Google and GitHub)
  • ✅ Rate limiting and brute force protection
  • ✅ Security headers and HTTPS configuration
  • ✅ Production deployment checklist

Time estimate: 60-90 minutes

Part 1: Common Security Vulnerabilities

Let's revisit the vulnerabilities from Part 1 and implement specific protections.

Vulnerability Matrix

Attack Risk Level What It Does How We Prevent It
XSS 🔴 Critical Injects malicious scripts httpOnly cookies, CSP headers, input sanitization
CSRF 🟡 High Forces unwanted actions SameSite cookies, CSRF tokens, Origin validation
Session Hijacking 🔴 Critical Steals user sessions HTTPS only, secure cookies, IP binding
Brute Force 🟡 High Guesses passwords Rate limiting, account lockout, strong passwords
SQL Injection 🔴 Critical Manipulates database queries Parameterized queries, ORM, input validation
Token Leakage 🟡 High Exposes auth tokens Never log tokens, short expiration, httpOnly
Man-in-the-Middle 🔴 Critical Intercepts traffic HTTPS/TLS, HSTS headers, certificate pinning

1. XSS Protection

Implementation:

// src/middleware/security.ts
import helmet from 'helmet';
import { Express } from 'express';

export function configureSecurityHeaders(app: Express) {
  // Use Helmet.js for security headers
  app.use(
    helmet({
      contentSecurityPolicy: {
        directives: {
          defaultSrc: ["'self'"],
          scriptSrc: ["'self'", "'unsafe-inline'"], // Adjust for your needs
          styleSrc: ["'self'", "'unsafe-inline'"],
          imgSrc: ["'self'", 'data:', 'https:'],
          connectSrc: ["'self'"],
          fontSrc: ["'self'"],
          objectSrc: ["'none'"],
          mediaSrc: ["'self'"],
          frameSrc: ["'none'"],
        },
      },
      crossOriginEmbedderPolicy: true,
      crossOriginOpenerPolicy: true,
      crossOriginResourcePolicy: { policy: 'same-site' },
      dnsPrefetchControl: true,
      frameguard: { action: 'deny' },
      hidePoweredBy: true,
      hsts: {
        maxAge: 31536000, // 1 year
        includeSubDomains: true,
        preload: true,
      },
      ieNoOpen: true,
      noSniff: true,
      referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
      xssFilter: true,
    })
  );
}

// Sanitize user input
import validator from 'validator';

export function sanitizeInput(input: string): string {
  return validator.escape(input);
}

export function validateEmail(email: string): boolean {
  return validator.isEmail(email);
}

Install dependencies:

npm install helmet validator
npm install -D @types/validator

2. CSRF Protection

// src/middleware/csrf.ts
import csrf from 'csurf';
import cookieParser from 'cookie-parser';

// CSRF protection for state-changing operations
export const csrfProtection = csrf({
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
  },
});

// Middleware to add CSRF token to responses
export function addCsrfToken(req: any, res: any, next: any) {
  res.locals.csrfToken = req.csrfToken();
  next();
}

// In routes that need CSRF protection:
// router.post('/api/sensitive', csrfProtection, handler);

Install:

npm install csurf
npm install -D @types/csurf

Frontend usage:

// Get CSRF token from meta tag or cookie
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;

// Include in POST requests
fetch('/api/sensitive', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken,
  },
  body: JSON.stringify({ data: 'value' }),
});

3. Rate Limiting

Prevent brute force attacks with rate limiting:

// src/middleware/rateLimit.ts
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';

// Create Redis client (for distributed rate limiting)
const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD,
});

/**
 * Strict rate limiter for auth endpoints
 * 5 attempts per 15 minutes
 */
export const authRateLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rl:auth:',
  }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 requests per window
  message: {
    error: 'Too many login attempts',
    message: 'Please try again after 15 minutes',
    retryAfter: 900, // seconds
  },
  standardHeaders: true, // Return rate limit info in headers
  legacyHeaders: false,
  // Slow down instead of blocking (optional)
  skipSuccessfulRequests: true, // Don't count successful logins
});

/**
 * General API rate limiter
 * 100 requests per 15 minutes
 */
export const apiRateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  message: {
    error: 'Too many requests',
    message: 'Please slow down',
  },
});

/**
 * Aggressive rate limiter for sensitive operations
 * 3 attempts per hour
 */
export const sensitiveRateLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rl:sensitive:',
  }),
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 3,
  message: {
    error: 'Too many attempts',
    message: 'Account temporarily locked. Try again in 1 hour.',
  },
});

Apply rate limiters:

// src/routes/auth.ts
import { authRateLimiter, sensitiveRateLimiter } from '../middleware/rateLimit';

// Login endpoint with rate limiting
router.post('/login', authRateLimiter, async (req, res) => {
  // Login logic
});

// Password reset with aggressive rate limiting
router.post('/reset-password', sensitiveRateLimiter, async (req, res) => {
  // Reset logic
});

Install:

npm install express-rate-limit rate-limit-redis ioredis
npm install -D @types/ioredis

4. SQL Injection Prevention

Always use parameterized queries:

// ❌ VULNERABLE - Never do this!
const query = `SELECT * FROM users WHERE username = '${username}'`;
await pool.query(query);

// ✅ SAFE - Use parameterized queries
const query = 'SELECT * FROM users WHERE username = $1';
await pool.query(query, [username]);

// ✅ EVEN BETTER - Use query builder or ORM
import { QueryBuilder } from 'knex';
const user = await db('users').where({ username }).first();

5. Secure Password Requirements

// src/utils/password.ts
import zxcvbn from 'zxcvbn';

export interface PasswordStrength {
  score: number; // 0-4
  feedback: string[];
  isStrong: boolean;
}

/**
 * Check password strength
 * Returns score from 0 (weak) to 4 (strong)
 */
export function checkPasswordStrength(password: string): PasswordStrength {
  const result = zxcvbn(password);

  return {
    score: result.score,
    feedback: [
      ...result.feedback.suggestions,
      ...(result.feedback.warning ? [result.feedback.warning] : []),
    ],
    isStrong: result.score >= 3, // Require score of 3 or 4
  };
}

/**
 * Validate password meets minimum requirements
 */
export function validatePassword(password: string): {
  valid: boolean;
  errors: string[];
} {
  const errors: string[] = [];

  if (password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }

  if (!/[a-z]/.test(password)) {
    errors.push('Password must contain lowercase letters');
  }

  if (!/[A-Z]/.test(password)) {
    errors.push('Password must contain uppercase letters');
  }

  if (!/[0-9]/.test(password)) {
    errors.push('Password must contain numbers');
  }

  if (!/[^a-zA-Z0-9]/.test(password)) {
    errors.push('Password must contain special characters');
  }

  const strength = checkPasswordStrength(password);
  if (!strength.isStrong) {
    errors.push('Password is too weak. ' + strength.feedback.join(' '));
  }

  return {
    valid: errors.length === 0,
    errors,
  };
}

Install:

npm install zxcvbn
npm install -D @types/zxcvbn

Part 2: Social Login with OAuth

Let's implement "Sign in with Google" and "Sign in with GitHub".

OAuth Flow Overview

1. User clicks "Sign in with Google"
2. Redirect to Google's login page
3. User authenticates with Google
4. Google redirects back with authorization code
5. Exchange code for access token
6. Use token to get user info from Google
7. Create/update user in your database
8. Create your own session (JWT)
9. User is logged in!

Google OAuth Setup

1. Create OAuth credentials:

Visit Google Cloud Console:

  • Create new project
  • Enable Google+ API
  • Create OAuth 2.0 Client ID
  • Add authorized redirect URI: http://localhost:3000/api/auth/google/callback

Add to .env:

GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback

2. Install passport.js:

npm install passport passport-google-oauth20
npm install -D @types/passport @types/passport-google-oauth20

3. Configure Passport:

// src/config/passport.ts
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { findOrCreateUser } from '../models/User';
import { config } from './env';

passport.use(
  new GoogleStrategy(
    {
      clientID: config.google.clientId,
      clientSecret: config.google.clientSecret,
      callbackURL: config.google.redirectUri,
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        // Extract user info from Google profile
        const email = profile.emails?.[0]?.value;
        const name = profile.displayName;
        const googleId = profile.id;

        if (!email) {
          return done(new Error('No email from Google'), undefined);
        }

        // Find or create user in database
        const user = await findOrCreateUser({
          email,
          username: email.split('@')[0],
          name,
          googleId,
          provider: 'google',
        });

        return done(null, user);
      } catch (error) {
        return done(error as Error, undefined);
      }
    }
  )
);

export default passport;

4. OAuth routes:

// src/routes/auth.ts
import passport from '../config/passport';

/**
 * GET /api/auth/google
 * Initiate Google OAuth flow
 */
router.get(
  '/google',
  passport.authenticate('google', {
    scope: ['profile', 'email'],
    session: false, // We're using JWT, not sessions
  })
);

/**
 * GET /api/auth/google/callback
 * Google redirects here after authentication
 */
router.get(
  '/google/callback',
  passport.authenticate('google', {
    session: false,
    failureRedirect: '/login?error=google_auth_failed',
  }),
  async (req, res) => {
    try {
      const user = req.user as any;

      // Generate our JWT tokens
      const accessToken = generateAccessToken(
        user.id.toString(),
        user.username
      );
      const refreshToken = generateRefreshToken(
        user.id.toString(),
        user.username
      );

      // Store refresh token
      const expiresAt = new Date();
      expiresAt.setDate(expiresAt.getDate() + 30);

      await storeRefreshToken(
        user.id,
        refreshToken,
        expiresAt,
        req.ip,
        req.get('user-agent')
      );

      // Set cookies
      res.cookie('access_token', accessToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 15 * 60 * 1000,
      });

      res.cookie('refresh_token', refreshToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 30 * 24 * 60 * 60 * 1000,
      });

      // Redirect to dashboard
      res.redirect('/dashboard');
    } catch (error) {
      console.error('Google callback error:', error);
      res.redirect('/login?error=callback_failed');
    }
  }
);

GitHub OAuth Setup

1. Create OAuth App:

Visit GitHub Settings:

  • New OAuth App
  • Callback URL: http://localhost:3000/api/auth/github/callback

Add to .env:

GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
GITHUB_REDIRECT_URI=http://localhost:3000/api/auth/github/callback

2. Configure Strategy:

npm install passport-github2
npm install -D @types/passport-github2
// src/config/passport.ts
import { Strategy as GitHubStrategy } from 'passport-github2';

passport.use(
  new GitHubStrategy(
    {
      clientID: config.github.clientId,
      clientSecret: config.github.clientSecret,
      callbackURL: config.github.redirectUri,
    },
    async (accessToken: string, refreshToken: string, profile: any, done: any) => {
      try {
        const email = profile.emails?.[0]?.value;
        const username = profile.username;
        const name = profile.displayName;
        const githubId = profile.id;

        if (!email) {
          return done(new Error('No email from GitHub'), undefined);
        }

        const user = await findOrCreateUser({
          email,
          username,
          name,
          githubId,
          provider: 'github',
        });

        return done(null, user);
      } catch (error) {
        return done(error, undefined);
      }
    }
  )
);

3. GitHub routes:

// src/routes/auth.ts

/**
 * GET /api/auth/github
 * Initiate GitHub OAuth flow
 */
router.get(
  '/github',
  passport.authenticate('github', {
    scope: ['user:email'],
    session: false,
  })
);

/**
 * GET /api/auth/github/callback
 * GitHub redirects here after authentication
 */
router.get(
  '/github/callback',
  passport.authenticate('github', {
    session: false,
    failureRedirect: '/login?error=github_auth_failed',
  }),
  async (req, res) => {
    // Same logic as Google callback
    // ... (generate tokens, set cookies, redirect)
  }
);

Frontend: Social Login Buttons

// LoginPage.tsx
export function LoginPage() {
  return (
    <div>
      <h1>Login</h1>

      {/* Traditional login */}
      <form onSubmit={handleLogin}>
        <input type="text" placeholder="Username" />
        <input type="password" placeholder="Password" />
        <button>Login</button>
      </form>

      {/* Social login */}
      <div className="social-login">
        <a
          href="http://localhost:3000/api/auth/google"
          className="btn btn-google"
        >
          <img src="/google-icon.svg" alt="" />
          Continue with Google
        </a>

        <a
          href="http://localhost:3000/api/auth/github"
          className="btn btn-github"
        >
          <img src="/github-icon.svg" alt="" />
          Continue with GitHub
        </a>
      </div>
    </div>
  );
}

Part 3: HTTPS Configuration

Never run authentication in production without HTTPS!

Development: Use mkcert

# Install mkcert
brew install mkcert  # macOS
# or
choco install mkcert  # Windows
# or
apt install mkcert  # Linux

# Create local CA
mkcert -install

# Generate certificate
mkcert localhost 127.0.0.1 ::1

# Creates:
# - localhost+2.pem (certificate)
# - localhost+2-key.pem (private key)

Update server:

// src/server.ts
import https from 'https';
import fs from 'fs';

const httpsOptions = {
  key: fs.readFileSync('./localhost+2-key.pem'),
  cert: fs.readFileSync('./localhost+2.pem'),
};

https.createServer(httpsOptions, app).listen(3000, () => {
  console.log('🔒 HTTPS server running on https://localhost:3000');
});

Production: Use Let's Encrypt

# Install certbot
sudo apt install certbot python3-certbot-nginx

# Get certificate
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

# Auto-renewal (certbot sets this up automatically)
sudo certbot renew --dry-run

Nginx configuration:

server {
    listen 443 ssl http2;
    server_name yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # Strong SSL settings
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    # HSTS header
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Proxy to Node.js app
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$server_name$request_uri;
}

Part 4: Production Deployment Checklist

Environment Variables

# .env.production
NODE_ENV=production

# Database
DATABASE_URL=postgresql://user:pass@host:5432/dbname
DATABASE_SSL=true

# JWT
JWT_SECRET=<generate-64-char-random-string>
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=30d

# OAuth
GOOGLE_CLIENT_ID=production-client-id
GOOGLE_CLIENT_SECRET=production-secret
GOOGLE_REDIRECT_URI=https://yourdomain.com/api/auth/google/callback

GITHUB_CLIENT_ID=production-client-id
GITHUB_CLIENT_SECRET=production-secret
GITHUB_REDIRECT_URI=https://yourdomain.com/api/auth/github/callback

# Redis
REDIS_URL=redis://user:pass@host:6379
REDIS_TLS=true

# Monitoring
SENTRY_DSN=https://your-sentry-dsn
LOG_LEVEL=info

Security Checklist

  • [ ] HTTPS enabled - All traffic encrypted
  • [ ] HSTS header - Force HTTPS
  • [ ] Secure cookies - secure: true flag set
  • [ ] httpOnly cookies - JavaScript can't access
  • [ ] SameSite cookies - CSRF protection
  • [ ] CSP headers - XSS protection
  • [ ] Rate limiting - Brute force protection
  • [ ] Input validation - All user input sanitized
  • [ ] Parameterized queries - SQL injection protection
  • [ ] Password strength - Minimum requirements enforced
  • [ ] bcrypt hashing - Passwords properly hashed
  • [ ] Token expiration - Short-lived access tokens
  • [ ] Refresh token rotation - Tokens rotated on use
  • [ ] Session management - Users can revoke sessions
  • [ ] Error messages - Don't leak sensitive info
  • [ ] Logging - Auth events logged (without sensitive data)
  • [ ] Monitoring - Alerts for suspicious activity

Database Security

-- Create read-only user for application
CREATE ROLE app_user WITH LOGIN PASSWORD 'strong_password';

-- Grant minimal permissions
GRANT SELECT, INSERT, UPDATE ON users TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON refresh_tokens TO app_user;
GRANT SELECT ON content TO app_user;

-- Don't grant DELETE on users (handle via application logic)
-- Don't grant schema modification permissions

Docker Deployment

# Dockerfile
FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

COPY . .
RUN npm run build

FROM node:18-alpine

WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

USER node
EXPOSE 3000

CMD ["node", "dist/server.js"]
# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=${DATABASE_URL}
      - JWT_SECRET=${JWT_SECRET}
    depends_on:
      - db
      - redis
    restart: unless-stopped

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: auth_system
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - /etc/letsencrypt:/etc/letsencrypt
    depends_on:
      - app
    restart: unless-stopped

volumes:
  postgres_data:
  redis_data:

Monitoring with Sentry

// src/server.ts
import * as Sentry from '@sentry/node';

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 1.0,
});

// Error handler
app.use(Sentry.Handlers.errorHandler());

Health Checks

// src/routes/health.ts
import express from 'express';
import { pool } from '../database/connection';
import { redis } from '../config/redis';

const router = express.Router();

router.get('/health', async (req, res) => {
  const checks = {
    server: 'ok',
    database: 'checking',
    redis: 'checking',
  };

  // Check database
  try {
    await pool.query('SELECT 1');
    checks.database = 'ok';
  } catch {
    checks.database = 'error';
  }

  // Check Redis
  try {
    await redis.ping();
    checks.redis = 'ok';
  } catch {
    checks.redis = 'error';
  }

  const allOk = Object.values(checks).every((status) => status === 'ok');

  res.status(allOk ? 200 : 503).json({
    status: allOk ? 'healthy' : 'unhealthy',
    checks,
    timestamp: new Date().toISOString(),
  });
});

export default router;

Summary

You've learned:

  • Security vulnerabilities - XSS, CSRF, SQL injection, and how to prevent them
  • Rate limiting - Protect against brute force attacks
  • Social login - OAuth with Google and GitHub
  • HTTPS configuration - Development and production setup
  • Production deployment - Complete checklist and Docker configuration
  • Monitoring - Health checks and error tracking

Series Complete!

Congratulations! You've built a production-ready authentication system from scratch. You now understand:

  1. Authentication fundamentals - JWT vs sessions, security concepts
  2. Implementation - Login, logout, password hashing, tokens
  3. Route protection - Middleware, guards, frontend integration
  4. Advanced features - Remember Me, refresh tokens, protected content
  5. Security & production - Hardening, OAuth, deployment

What's Next?

Continue improving your authentication system:

  • Add 2FA - Time-based one-time passwords (TOTP)
  • Magic links - Passwordless email authentication
  • Biometric auth - WebAuthn/FIDO2 integration
  • Audit logging - Comprehensive security event tracking
  • Geolocation - Block suspicious locations
  • Device fingerprinting - Detect account takeovers

Resources


Series complete! ← Back to Part 1

Questions? Security is an ongoing process. Stay updated on new vulnerabilities and best practices. Never stop learning!