Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 068 / 100

Security fundamentals every engineer needs: a deep dive

Lessons from my cybersecurity master's and real projects.

Expona Admin9 min read

I've seen enough production incidents to know that security isn't optional—it's foundational. Whether you're building an e-commerce platform handling payments or a SaaS system storing customer data, security fundamentals every engineer needs to understand aren't theoretical exercises. They're the difference between a system your users can trust and one that becomes a liability. In this article, I'm sharing what I learned from my cybersecurity master's and what real production incidents taught me.

Validate all input — Every external input is untrusted until validated

The most common vulnerability I've encountered isn't sophisticated—it's lazy input handling. Every string that comes from a client, API request, form field, or database query parameter is potential attack surface. I've seen SQL injection attempts slip through, NoSQL injection queries, and command injection payloads because someone assumed "users won't do that."

Input validation means checking three things: format, length, and content. If you expect an email, validate it's actually an email format. If you expect a number between 1 and 100, reject anything outside that range. If you expect a UUID, reject random strings. The key principle is whitelist, not blacklist—define what's allowed, and reject everything else.

Here's what I do in NestJS applications:

typescript
import { IsEmail, IsString, MinLength, MaxLength, IsUUID } from 'class-validator';
import { BadRequestException, Controller, Post, Body } from '@nestjs/common';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  @MaxLength(128)
  password: string;

  @IsString()
  @MinLength(1)
  @MaxLength(255)
  name: string;
}

@Controller('users')
export class UsersController {
  @Post()
  async createUser(@Body() createUserDto: CreateUserDto) {
    // NestJS class-validator pipes automatically validate
    // before this handler runs. Invalid input never reaches here.
    return this.usersService.create(createUserDto);
  }
}

For query parameters and URL segments, treat them the same way:

typescript
@Get(':orderId')
async getOrder(@Param('orderId') orderId: string) {
  // Validate orderId is a valid UUID before querying database
  if (!this.isValidUUID(orderId)) {
    throw new BadRequestException('Invalid order ID format');
  }
  return this.ordersService.findById(orderId);
}

private isValidUUID(value: string): boolean {
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
  return uuidRegex.test(value);
}

In a trading platform I built, we had real-time order streams. A malicious actor tried injecting control characters into order metadata fields, hoping to trigger buffer overflows in downstream processing. Strict input validation caught it immediately—the validation rejected the payload, and our monitoring alerted the security team.

Authenticate and authorize every request — Check permissions on the server for every action

Authentication answers "who are you?" Authorization answers "are you allowed to do this?" Both must happen on the server. Never trust the client.

I've seen front-end developers hide admin buttons with JavaScript conditionals, then assume those endpoints are protected. They're not. Anyone can call your API directly. I've audited APIs where a user could change their user ID in a request body and access another user's data because authorization checks only looked at the authenticated session, not whether that session owned the resource.

Here's the pattern I use:

typescript
import { Injectable, ForbiddenException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class OrdersService {
  async getOrder(orderId: string, userId: string) {
    const order = await this.ordersRepository.findById(orderId);
    
    if (!order) {
      throw new NotFoundException('Order not found');
    }

    // Authorization check: does this user own this order?
    if (order.userId !== userId) {
      throw new ForbiddenException('You do not have permission to view this order');
    }

    return order;
  }

  async updateOrder(orderId: string, userId: string, updates: UpdateOrderDto) {
    const order = await this.ordersRepository.findById(orderId);

    // Check ownership BEFORE any update
    if (order.userId !== userId) {
      throw new ForbiddenException('You do not have permission to modify this order');
    }

    // Additional check: can this user modify this status?
    if (updates.status && !this.canTransitionTo(order.status, updates.status, order.role)) {
      throw new ForbiddenException('Invalid status transition for your role');
    }

    return this.ordersRepository.update(orderId, updates);
  }

  private canTransitionTo(currentStatus: string, newStatus: string, userRole: string): boolean {
    // Role-based authorization logic
    const transitions = {
      admin: ['pending', 'processing', 'shipped', 'cancelled'],
      customer: ['cancelled'], // only customers can cancel their own orders
    };
    return transitions[userRole]?.includes(newStatus) ?? false;
  }
}

In a ride-hailing platform, I caught an authorization bug during code review where drivers could see another driver's earnings report by modifying the driver ID in the URL. The frontend never showed that option, but the API didn't validate ownership. Authorization checks prevented a data leak.

Protect secrets — Never in code; rotate regularly; least privilege access

Secrets include database passwords, API keys, encryption keys, JWT signing keys, and third-party credentials. I've searched GitHub history on leaked repositories and found credentials that had been in production for years. Once a secret is in git history, assume it's compromised—even if you delete it, it's still there.

Three rules: never commit secrets, rotate them regularly, and use least privilege—give each service only the credentials it needs.

For development and production:

typescript
// ❌ DO NOT DO THIS
const dbPassword = 'MySecretPassword123!';
const apiKey = 'sk_live_abc123xyz789';

// ✅ DO THIS
import * as dotenv from 'dotenv';

dotenv.config();

const dbPassword = process.env.DATABASE_PASSWORD;
const apiKey = process.env.STRIPE_API_KEY;

if (!dbPassword || !apiKey) {
  throw new Error('Missing required environment variables');
}

Your .env file should never be committed. Add it to .gitignore:

typescript
# .gitignore
.env
.env.local
.env.*.local

In production, use your cloud provider's secret management. AWS Secrets Manager, Azure Key Vault, or Google Cloud Secret Manager—they handle encryption, rotation, and audit logging:

typescript
import { SecretsManager } from '@aws-sdk/client-secrets-manager';

const secretsManager = new SecretsManager({ region: 'us-east-1' });

export async function getDatabaseCredentials() {
  const secret = await secretsManager.getSecretValue({
    SecretId: 'prod/database/credentials',
  });
  
  return JSON.parse(secret.SecretString);
}

For least privilege: a Lambda function that only reads from a specific S3 bucket shouldn't have permissions to delete from it or access other buckets. A microservice that calls only two external APIs shouldn't have API keys for ten services. Each database user should have only the permissions it needs—a read-only service shouldn't have ALTER TABLE permissions.

On a fintech platform, a service breach exposed API keys that had overly broad permissions. Because we'd applied least privilege, the damage was limited to one narrow feature rather than full database access.

Patch dependencies — Most breaches exploit known, unpatched vulnerabilities

The majority of security incidents I've investigated exploited known vulnerabilities with public patches. A library used for JWT parsing had a well-documented flaw, and teams running old versions were compromised.

Dependency management is ongoing work, not a one-time task. Scan for vulnerabilities regularly, automate updates where possible, and keep a clear process for applying patches.

typescript
// Check for vulnerabilities in your project
npm audit

// Fix automatically (careful—review changes)
npm audit fix

// Update to latest versions (review breaking changes)
npm update

I use automated dependency scanning in CI/CD. GitHub's Dependabot, Snyk, and npm audit will find vulnerabilities and propose patches. Set up automated pull requests for minor and patch updates, and manually review major version updates.

In package.json, use semantic versioning wisely:

typescript
{
  "dependencies": {
    "@nestjs/common": "^10.2.0",    // allows 10.x.x updates
    "jsonwebtoken": "^9.0.0",       // allows 9.x.x updates
    "bcryptjs": "~2.4.3"            // only allows 2.4.x updates (patch-only)
  }
}

Use caret (^) for libraries you trust to follow semantic versioning. Use tilde (~) for security-critical libraries to avoid unexpected breaking changes. Regularly run npm audit to identify outdated packages with known vulnerabilities.

Log security events — Failed logins and permission denials need monitoring

Security logs are your early warning system. Unusual patterns in failed logins might indicate brute force attacks. Permission denials could reveal reconnaissance attempts. I've caught compromised accounts by analyzing login patterns—suddenly logging in from five countries in one hour is suspicious.

Log these events:

  • Authentication attempts (successful and failed)
  • Authorization failures (permission denied events)
  • Privilege escalations
  • Sensitive data access
  • Configuration changes
  • API key usage

In NestJS, I use structured logging with context:

typescript
import { Injectable, Logger } from '@nestjs/common';

@Injectable()
export class AuthService {
  private readonly logger = new Logger(AuthService.name);

  async login(email: string, password: string, ipAddress: string) {
    try {
      const user = await this.usersRepository.findByEmail(email);
      
      if (!user || !this.validatePassword(password, user.passwordHash)) {
        this.logger.warn(
          `Failed login attempt`,
          {
            email,
            ipAddress,
            timestamp: new Date().toISOString(),
            reason: 'invalid_credentials',
          }
        );
        throw new UnauthorizedException('Invalid credentials');
      }

      this.logger.log(
        `User login successful`,
        {
          userId: user.id,
          email,
          ipAddress,
          timestamp: new Date().toISOString(),
        }
      );

      return this.generateToken(user);
    } catch (error) {
      this.logger.error(
        `Login error`,
        error,
        {
          email,
          ipAddress,
        }
      );
      throw error;
    }
  }

  async revokePermission(userId: string, permission: string, adminId: string) {
    this.logger.warn(
      `Permission revoked`,
      {
        userId,
        permission,
        revokedBy: adminId,
        timestamp: new Date().toISOString(),
      }
    );
    
    return this.permissionsRepository.revoke(userId, permission);
  }
}

Send these logs to a centralized system—CloudWatch, DataDog, Splunk, or ELK stack. Set up alerts for suspicious patterns: ten failed logins in a minute, permission denials from a new IP, unusual API access patterns. I've prevented account compromises by alerting when legitimate users had sudden permission denials they couldn't explain.

Common mistakes

  • Client-side validation only: Developers validate input in React or Vue and assume the API is protected. It's not. Always validate on the server.
  • Authorization in middleware only: Checking authentication once at the route level doesn't mean every resource access is authorized. Verify ownership and permissions for every specific resource.
  • Secrets in environment but not rotating: Using .env is better than hardcoding, but if you never rotate keys, a leaked key stays valid forever. Rotate quarterly at minimum.
  • Ignoring dependency warnings: Teams see npm audit warnings and tell themselves they'll fix it "next sprint." Vulnerabilities get exploited before next sprint.
  • Logging sensitive data: Don't log passwords, credit card numbers, or full API keys. Log enough to investigate without creating a security liability.

Key takeaways

  • Validate all input on the server—never trust the client, never assume format, never blacklist-only.
  • Authorize every request—check on the server that the authenticated user owns the resource and has permission for the action.
  • Protect secrets—use environment variables in dev, secret managers in production, rotate regularly, and apply least privilege.
  • Patch dependencies—automate scanning, apply patch updates promptly, manually review major updates, and keep a clear process.
  • Log security events—track failed logins, permission denials, and privilege changes, send to centralized systems, and alert on suspicious patterns.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles