DEV Community

Cover image for Web Security Is Everyone's Job: A Developer's Field Guide
Olawale Afuye
Olawale Afuye

Posted on

Web Security Is Everyone's Job: A Developer's Field Guide

Most web security guides cover the classics. XSS. SQL injection. CSRF. The OWASP Top 10. Those matter, and if you haven't read them, start there.

But modern attacks don't stop at the classics.

Today's breaches happen through forgotten API endpoints, leaked secrets in .env files committed to public repos, authorization checks that were never written, and npm packages that were compromised six months before anyone noticed.

This guide covers the full picture — including the five areas most security articles skip entirely.


Part 1: Authentication and Session Security

Authentication answers one question: who are you?

Getting it wrong is catastrophic. But "getting it right" means more than hashing passwords and setting a cookie. Sessions have their own threat surface, and most guides don't go deep enough on it.

Password Hashing

Never store passwords in plaintext or with reversible encryption. Use a slow, purpose-built hashing algorithm.

// ✅ Correct — bcrypt adds salt automatically and is deliberately slow
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(password, 12); // cost factor of 12

// ✅ Also acceptable — Argon2 is the modern standard
const argon2 = require('argon2');
const hash = await argon2.hash(password);

// ❌ Never do this
const md5 = require('md5');
const hash = md5(password); // fast = bad for passwords
Enter fullscreen mode Exit fullscreen mode

Use bcrypt, Argon2, or scrypt. Never MD5, SHA-1, or SHA-256 alone — these are fast by design, which makes brute-force practical.


CSRF Protection

Cross-Site Request Forgery tricks an authenticated user's browser into making a request on a malicious site's behalf. If your state-changing endpoints don't verify request origin, any site can trigger them.

// Express + csurf middleware
const csrf = require('csurf');
app.use(csrf({ cookie: true }));

// In your route, pass the token to the client
app.get('/form', (req, res) => {
  res.render('form', { csrfToken: req.csrfToken() });
});

// The form includes the token
// <input type="hidden" name="_csrf" value="<%= csrfToken %>">
Enter fullscreen mode Exit fullscreen mode

Modern SameSite cookie attributes reduce CSRF risk significantly — but they're not a complete replacement for CSRF tokens on sensitive endpoints.


Secure Cookie Flags

Cookies are the primary session transport. Three flags make them dramatically harder to steal or misuse:

Set-Cookie: session=abc123;
  HttpOnly;       // JS cannot read this cookie — prevents XSS theft
  Secure;         // Only sent over HTTPS
  SameSite=Strict // Not sent on cross-site requests — kills CSRF
Enter fullscreen mode Exit fullscreen mode
Flag What It Prevents
HttpOnly XSS scripts reading your session cookie
Secure Cookie transmission over plain HTTP
SameSite=Strict CSRF attacks via cross-origin requests
SameSite=Lax Most CSRF, while allowing top-level navigations

Set all three. There is almost no legitimate reason not to.


Session Fixation and Session Rotation

Session fixation is a lesser-known but serious attack. The attacker tricks a user into using a session ID they already know — then waits for the user to authenticate. Once the user logs in, the attacker has a valid authenticated session.

The fix is simple: rotate the session ID on login.

// Express-session example
app.post('/login', async (req, res) => {
  const user = await authenticate(req.body);
  if (!user) return res.status(401).send('Invalid credentials');

  // ✅ Regenerate session after login — destroys old session ID
  req.session.regenerate((err) => {
    if (err) return next(err);
    req.session.userId = user.id;
    res.redirect('/dashboard');
  });
});
Enter fullscreen mode Exit fullscreen mode

Never reuse a pre-login session ID after authentication. New user state = new session ID. Always.


JWT Security

JWTs are everywhere. They're also misunderstood in ways that create serious vulnerabilities.

The none algorithm attack:

// ❌ Vulnerable — accepts unsigned tokens if alg is "none"
jwt.verify(token, secret, { algorithms: ['HS256', 'none'] });

// ✅ Explicitly specify only the algorithm you expect
jwt.verify(token, secret, { algorithms: ['HS256'] });
Enter fullscreen mode Exit fullscreen mode

What to store in a JWT — and what not to:

// ✅ Minimal, safe payload
const token = jwt.sign(
  { sub: user.id, role: user.role },
  process.env.JWT_SECRET,
  { expiresIn: '15m' } // Short-lived access tokens
);

// ❌ Never put sensitive data in the payload
// JWT payloads are base64-encoded, not encrypted — anyone can decode them
const bad = jwt.sign({ password: user.password, ssn: user.ssn }, secret);
Enter fullscreen mode Exit fullscreen mode

Key JWT rules:

  • Keep access tokens short-lived (15 minutes is a reasonable default)
  • Never store sensitive data in the payload — it's encoded, not encrypted
  • Use strong secrets (32+ random bytes minimum)
  • Always validate exp, iss, and aud claims

Refresh Token Rotation

Short-lived access tokens are good. But users can't re-authenticate every 15 minutes. The solution is a long-lived refresh token that issues new access tokens — but only once.

// On login — issue both tokens
const accessToken = jwt.sign({ sub: user.id }, ACCESS_SECRET, { expiresIn: '15m' });
const refreshToken = generateSecureToken(); // e.g., crypto.randomBytes(64).toString('hex')

// Store refresh token hash in DB, associated with user
await db.refreshTokens.create({
  userId: user.id,
  tokenHash: hash(refreshToken),
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});

// On refresh — rotate: invalidate old, issue new
app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.cookies;

  const record = await db.refreshTokens.findByHash(hash(refreshToken));
  if (!record || record.expiresAt < new Date()) {
    return res.status(401).send('Invalid or expired refresh token');
  }

  // ✅ Invalidate the used token immediately (rotation)
  await db.refreshTokens.delete(record.id);

  // Issue new pair
  const newAccess = jwt.sign({ sub: record.userId }, ACCESS_SECRET, { expiresIn: '15m' });
  const newRefresh = generateSecureToken();
  await db.refreshTokens.create({ userId: record.userId, tokenHash: hash(newRefresh) });

  res.json({ accessToken: newAccess });
  // Set newRefresh as HttpOnly cookie
});
Enter fullscreen mode Exit fullscreen mode

Rotation means a stolen refresh token can only be used once. If you detect a replay (the old token used after rotation), it signals a compromise — invalidate all sessions for that user immediately.


Token Revocation

JWTs are stateless, which is their strength and their weakness. Once issued, a token is valid until it expires — even if the user logs out, resets their password, or gets banned.

Strategies to solve this:

Option 1 — Blocklist invalidated tokens:

// On logout or password change
await redis.setex(`revoked:${jti}`, tokenTTL, 'true');

// On every request
const isRevoked = await redis.get(`revoked:${jti}`);
if (isRevoked) return res.status(401).send('Token revoked');
Enter fullscreen mode Exit fullscreen mode

Option 2 — Short expiry + refresh token as the revocation point:
Keep access tokens short (15m). On logout, delete the refresh token from the database. The access token expires naturally and can't be renewed.

For high-security operations (password changes, role changes), always invalidate all active sessions immediately regardless of approach.


Part 2: Authorization

Authentication answers who are you?

Authorization answers: what are you allowed to do?

This distinction matters enormously. Many modern breaches aren't authentication failures — the attacker authenticated just fine. The breach happened because authorization was never properly implemented or enforced.


IDOR — Insecure Direct Object Reference

This is one of the most common API vulnerabilities in production systems today. It looks like this:

GET /api/users/123/profile      ← You
GET /api/users/124/profile      ← Not you — but does the server stop you?
Enter fullscreen mode Exit fullscreen mode

If the server returns user 124's data without checking that the requesting user owns or has permission to view that record, that's an IDOR vulnerability.

// ❌ Broken — fetches based on URL param, no ownership check
app.get('/api/users/:id/profile', auth, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json(user);
});

// ✅ Fixed — verify the requesting user owns this resource
app.get('/api/users/:id/profile', auth, async (req, res) => {
  if (req.user.id !== req.params.id && req.user.role !== 'admin') {
    return res.status(403).send('Forbidden');
  }
  const user = await db.users.findById(req.params.id);
  res.json(user);
});
Enter fullscreen mode Exit fullscreen mode

The pattern to follow: never trust the client to tell you what they're allowed to access. Always verify server-side.


Broken Object Level Authorization (BOLA)

BOLA is the API-era name for IDOR, and it's OWASP API Security's #1 risk. Every object accessed via an API must be authorization-checked, not just authenticated.

// Scenario: User requests their own order
// ❌ No authorization check
app.get('/api/orders/:orderId', auth, async (req, res) => {
  const order = await db.orders.findById(req.params.orderId);
  res.json(order);
});

// ✅ Enforce ownership at the query level
app.get('/api/orders/:orderId', auth, async (req, res) => {
  const order = await db.orders.findOne({
    id: req.params.orderId,
    userId: req.user.id  // Ownership enforced in the query itself
  });

  if (!order) return res.status(404).send('Not found');
  res.json(order);
});
Enter fullscreen mode Exit fullscreen mode

A practical rule: if you're doing findById followed by a permission check, refactor so the permission is part of the query. Fewer round trips, fewer gaps.


Broken Function Level Authorization

This is about endpoints, not objects. The question isn't "can you see this record" but "can you perform this action at all?"

// ❌ Route exists but has no role check
app.delete('/api/admin/users/:id', auth, async (req, res) => {
  await db.users.delete(req.params.id);
  res.send('Deleted');
});

// ✅ Role enforced via middleware
const requireRole = (role) => (req, res, next) => {
  if (req.user.role !== role) return res.status(403).send('Forbidden');
  next();
};

app.delete('/api/admin/users/:id', auth, requireRole('admin'), async (req, res) => {
  await db.users.delete(req.params.id);
  res.send('Deleted');
});
Enter fullscreen mode Exit fullscreen mode

Admin routes that don't enforce admin roles are surprisingly common. Treat every route as untrusted and define authorization explicitly — don't rely on the UI not exposing a link.


A Practical Authorization Checklist

Before any endpoint goes to production:

  • [ ] Does this endpoint require authentication?
  • [ ] Does this endpoint require a specific role or permission?
  • [ ] If it returns or modifies a specific resource, does it verify the requester owns or has access to that resource?
  • [ ] Are these checks server-side, not client-side?
  • [ ] Are these checks covered by automated tests?

Part 3: API Security

Most modern systems aren't browser → server. They look more like this:

Mobile App / SPA
      ↓
  API Gateway
      ↓
  Microservices
      ↓
   Databases
Enter fullscreen mode Exit fullscreen mode

Every arrow in that diagram is an attack surface. Here's how to secure it.


Rate Limiting

Unprotected APIs are trivially brute-forced, scraped, or abused. Rate limiting is non-negotiable.

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

// General API rate limit
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  message: { error: 'Too many requests, please try again later.' }
});

// Stricter limit on auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10, // 10 login attempts per 15 minutes per IP
  skipSuccessfulRequests: true
});

app.use('/api/', apiLimiter);
app.use('/api/auth/', authLimiter);
Enter fullscreen mode Exit fullscreen mode

For distributed systems, use a Redis-backed store so limits work across multiple instances:

const RedisStore = require('rate-limit-redis');
const rateLimit = rateLimit({
  store: new RedisStore({ client: redisClient }),
  windowMs: 15 * 60 * 1000,
  max: 100
});
Enter fullscreen mode Exit fullscreen mode

API Key Management

API keys are credentials. Treat them accordingly.

// ❌ Raw key stored in DB
await db.apiKeys.create({ key: rawKey, userId });

// ✅ Hash the key — store only the hash
const crypto = require('crypto');
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
await db.apiKeys.create({ keyHash, userId, lastUsed: null });

// On verification
const incoming = crypto.createHash('sha256').update(req.header('X-API-Key')).digest('hex');
const record = await db.apiKeys.findOne({ keyHash: incoming });
Enter fullscreen mode Exit fullscreen mode

Additional API key practices:

  • Scope keys to specific permissions (read-only, write, admin)
  • Implement expiration and rotation
  • Log usage — who used this key, when, for what
  • Provide a revocation mechanism
  • Never log the raw key value anywhere

OAuth 2.0 — Getting It Right

OAuth is the standard for delegated authorization. It's also frequently misimplemented.

User → Your App → Authorization Server (e.g. Google)
                         ↓
               Authorization Code
                         ↓
            Your Backend exchanges for tokens
                         ↓
                   Access Token
Enter fullscreen mode Exit fullscreen mode

Critical implementation notes:

// ✅ Always validate the state parameter — prevents CSRF on OAuth flow
app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;

  // Verify state matches what you stored before redirect
  if (state !== req.session.oauthState) {
    return res.status(403).send('State mismatch — possible CSRF');
  }

  // Exchange code for tokens on the backend, not the frontend
  const tokens = await exchangeCodeForTokens(code);
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Never use the Implicit Flow (tokens returned directly in URL fragments). Always use Authorization Code + PKCE for public clients (SPAs, mobile apps).


Service-to-Service Authentication

Internal services calling each other need authentication too. Assume a compromised microservice could make requests on behalf of another.

Options:

  • mTLS (Mutual TLS): Both sides present certificates — common in service meshes like Istio
  • Short-lived service tokens: Each service gets a JWT for internal calls with a very short TTL
  • API keys with IP allowlisting: Simpler, but less flexible
// Service-to-service token validation middleware
const validateServiceToken = async (req, res, next) => {
  const token = req.header('X-Service-Token');
  try {
    const payload = jwt.verify(token, process.env.INTERNAL_SERVICE_SECRET);
    if (payload.type !== 'service') return res.status(403).send('Forbidden');
    req.callerService = payload.service;
    next();
  } catch {
    res.status(401).send('Invalid service token');
  }
};
Enter fullscreen mode Exit fullscreen mode

GraphQL Security

GraphQL introduces security challenges that REST doesn't have.

Introspection in production:

// ❌ Default — exposes your entire schema to anyone
const server = new ApolloServer({ schema });

// ✅ Disable introspection in production
const server = new ApolloServer({
  schema,
  introspection: process.env.NODE_ENV !== 'production'
});
Enter fullscreen mode Exit fullscreen mode

Query depth and complexity limits:

const depthLimit = require('graphql-depth-limit');
const { createComplexityLimitRule } = require('graphql-validation-complexity');

const server = new ApolloServer({
  schema,
  validationRules: [
    depthLimit(5),  // Prevents deeply nested query attacks
    createComplexityLimitRule(1000)  // Prevents complexity-based DoS
  ]
});
Enter fullscreen mode Exit fullscreen mode

N+1 and batching: Use DataLoader to prevent N+1 queries that could be exploited for resource exhaustion.


Request Signing

For high-security API communication, request signing ensures the request wasn't tampered with in transit and proves it came from a specific client.

// Signing (client side)
const crypto = require('crypto');

function signRequest(method, path, body, secret) {
  const timestamp = Date.now().toString();
  const payload = `${method}\n${path}\n${timestamp}\n${JSON.stringify(body)}`;
  const signature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  return { signature, timestamp };
}

// Verification (server side)
function verifySignature(req, secret) {
  const { signature, timestamp } = req.headers;

  // Reject requests older than 5 minutes — prevents replay attacks
  if (Date.now() - parseInt(timestamp) > 5 * 60 * 1000) return false;

  const payload = `${req.method}\n${req.path}\n${timestamp}\n${JSON.stringify(req.body)}`;
  const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Enter fullscreen mode Exit fullscreen mode

AWS uses this pattern (SigV4). Stripe uses it for webhooks. It's the right approach whenever you need cryptographic proof of request integrity.


Part 4: Supply Chain Security

You write your own code carefully. But your application is also made of hundreds of thousands of lines of code you didn't write, by people you've never met, distributed as packages you installed in 30 seconds.

That's your supply chain. And it's increasingly the target.


The Real Threat Surface

Typosquatting — malicious packages with names one character off from popular ones:

npm install cross-env     # Legitimate
npm install crossenv      # Malicious package — real incident, 2018
npm install lodash        # Legitimate
npm install 1odash        # Hypothetical typosquat — lowercase L vs 1
Enter fullscreen mode Exit fullscreen mode

Dependency confusion — publishing a public package with the same name as an internal private one to intercept installations in misconfigured environments. This is how a researcher compromised Apple, Microsoft, and Shopify in 2021.

Compromised maintainers — legitimate packages taken over through account compromise or maintainer handoff. The event-stream incident in 2018 involved exactly this.

Poisoned CI/CD — SolarWinds demonstrated that attackers who compromise your build pipeline can inject malicious code that never appears in your source repo.


Practical Defenses

Lock your dependency tree:

# Always commit package-lock.json or yarn.lock
# Use --frozen-lockfile in CI to prevent silent upgrades
npm ci --frozen-lockfile

# Pin exact versions for critical dependencies
npm install lodash@4.17.21  # Exact pin, not ^4.17.21
Enter fullscreen mode Exit fullscreen mode

Audit regularly:

npm audit
npm audit --audit-level=high  # Fail CI on high-severity issues

# For more comprehensive scanning
npx snyk test
Enter fullscreen mode Exit fullscreen mode

Subresource Integrity (SRI) for CDN assets:

<!-- ✅ Browser verifies the file hash before executing -->
<script
  src="https://cdn.example.com/lib.min.js"
  integrity="sha384-abc123..."
  crossorigin="anonymous">
</script>
Enter fullscreen mode Exit fullscreen mode

Vet before you install:

  • Check the package's weekly downloads and GitHub star trajectory
  • Look at when it was last published and whether it's actively maintained
  • Review the install script (npm install can execute arbitrary code via preinstall hooks)
  • Use socket.dev or Snyk for automated supply chain analysis

Scope your npm tokens in CI:

# Use read-only tokens in CI where you only need to install
# Use publish-scoped tokens only in release pipelines
# Rotate tokens regularly
# Never use personal access tokens in shared environments
Enter fullscreen mode Exit fullscreen mode

Securing Your CI/CD Pipeline

# GitHub Actions — use commit SHA pinning, not tag pinning
# ❌ Tags can be moved to point to different code
- uses: actions/checkout@v3

# ✅ Pinning to a specific commit SHA is immutable
- uses: actions/checkout@8e5e7e5a8e3...  # Full SHA

# Restrict permissions to minimum required
permissions:
  contents: read        # Don't grant write unless needed
  id-token: write       # Only for OIDC-based cloud auth

# Use environment secrets, not repo secrets for production
# Run security scans as a required CI step
Enter fullscreen mode Exit fullscreen mode

Part 5: Secrets Management

Developers leak secrets constantly. Not maliciously — carelessly. A .env file committed to a public repo. A hardcoded API key in a utility script. A database password in a log line.

This section is the most practical in this entire guide.


What Counts as a Secret

Everything in this category must be treated as a secret:

  • Database credentials (connection strings, passwords)
  • API keys and tokens (third-party services)
  • JWT signing secrets
  • Encryption keys
  • Cloud credentials (AWS access keys, GCP service account keys)
  • Private certificates and keys
  • Webhook signing secrets

The Anti-Patterns That Get People Fired

// ❌ Hardcoded secrets
const db = new Client({ password: 'myS3cretP@ss' });

// ❌ Secrets in environment variable names that get logged
console.log('Config:', process.env); // This logs everything

// ❌ Secrets in client-side code
const apiKey = 'sk-live-abc123'; // Visible to anyone viewing source

// ❌ .env files committed to version control
// (even if you delete them, they remain in git history)
Enter fullscreen mode Exit fullscreen mode

The Right Approach: Secrets Managers

For production systems, don't manage secrets yourself. Use a dedicated secrets manager.

// AWS Secrets Manager
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');

const client = new SecretsManagerClient({ region: 'us-east-1' });

async function getSecret(secretName) {
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: secretName })
  );
  return JSON.parse(response.SecretString);
}

// Usage — fetch at startup, not hardcoded
const { DB_PASSWORD, JWT_SECRET } = await getSecret('prod/myapp/secrets');
Enter fullscreen mode Exit fullscreen mode
// HashiCorp Vault
const vault = require('node-vault')({ endpoint: 'https://vault.company.com' });
await vault.approleLogin({ role_id: ROLE_ID, secret_id: SECRET_ID });

const { data } = await vault.read('secret/data/myapp');
const dbPassword = data.data.DB_PASSWORD;
Enter fullscreen mode Exit fullscreen mode

Comparison:

Tool Best For
HashiCorp Vault Self-hosted, complex access policies, dynamic secrets
AWS Secrets Manager AWS-native workloads, automatic rotation
Azure Key Vault Azure workloads
GCP Secret Manager GCP workloads
Doppler / Infisical Developer-friendly, cloud-agnostic

Preventing Accidental Secret Leaks

Pre-commit hooks to catch secrets before they land:

# Install git-secrets or gitleaks
brew install gitleaks

# Run in CI
gitleaks detect --source . --verbose

# Or add as a pre-commit hook
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
gitleaks protect --staged -v
if [ $? -ne 0 ]; then
  echo "⛔ Secrets detected. Commit blocked."
  exit 1
fi
EOF
chmod +x .git/hooks/pre-commit
Enter fullscreen mode Exit fullscreen mode

If you've already committed a secret:

  1. Rotate the secret immediately — assume it's compromised
  2. Remove it from history with git filter-repo (not git filter-branch)
  3. Force-push all affected branches
  4. Audit access logs for the exposed secret
# Remove a secret from git history
pip install git-filter-repo
git filter-repo --path-glob '*.env' --invert-paths
Enter fullscreen mode Exit fullscreen mode

The most important rule: rotating the secret is always the first step. Git history cleanup comes second. Don't spend 30 minutes cleaning history while the leaked key is still active.


Secret Rotation

Secrets should be rotated regularly and always immediately on suspected compromise.

// Implement rotation-aware config loading
class SecretManager {
  constructor() {
    this.cache = new Map();
    this.ttl = 15 * 60 * 1000; // Refresh every 15 minutes
  }

  async get(secretName) {
    const cached = this.cache.get(secretName);
    if (cached && Date.now() - cached.fetchedAt < this.ttl) {
      return cached.value;
    }

    const value = await fetchFromSecretsManager(secretName);
    this.cache.set(secretName, { value, fetchedAt: Date.now() });
    return value;
  }
}
Enter fullscreen mode Exit fullscreen mode

Automated rotation for database credentials is supported natively in AWS Secrets Manager and Vault. For JWT secrets, coordinate rotation: support both old and new secrets for a brief transition window.


Putting It All Together: A Security Checklist

Authentication & Sessions

  • [ ] Passwords hashed with bcrypt, Argon2, or scrypt (never MD5/SHA alone)
  • [ ] Session ID rotated on login (prevents fixation)
  • [ ] All cookies set with HttpOnly, Secure, SameSite
  • [ ] JWTs: algorithm pinned, payload contains no sensitive data, expiry is short
  • [ ] Refresh tokens rotated on every use
  • [ ] Token revocation implemented for logout and credential changes

Authorization

  • [ ] Every endpoint explicitly defines who can access it
  • [ ] Resource-level ownership verified server-side (not just authenticated)
  • [ ] Admin/privileged routes protected with role middleware
  • [ ] Authorization covered by integration tests

API Security

  • [ ] Rate limiting on all public endpoints (stricter on auth routes)
  • [ ] API keys hashed in storage, scoped, and expirable
  • [ ] OAuth using Authorization Code + PKCE (not Implicit Flow)
  • [ ] GraphQL introspection disabled in production
  • [ ] Service-to-service calls authenticated
  • [ ] Request signing on high-security internal APIs

Supply Chain

  • [ ] package-lock.json / yarn.lock committed and used in CI (npm ci)
  • [ ] npm audit run in CI pipeline
  • [ ] New dependencies reviewed before installation
  • [ ] SRI hashes on CDN-loaded scripts
  • [ ] GitHub Actions (and equivalent) pinned to commit SHAs
  • [ ] gitleaks or equivalent scanning in pre-commit hook and CI

Secrets Management

  • [ ] No secrets in source code, config files, or environment variable logs
  • [ ] .env in .gitignore, no .env files in git history
  • [ ] Production secrets stored in a secrets manager (Vault, AWS SM, etc.)
  • [ ] Secret rotation policy defined and automated where possible
  • [ ] Pre-commit hooks scanning for accidentally added secrets

Conclusion

Security isn't a feature you add at the end. It's a discipline you build into every layer of what you ship.

The checklist above is not a one-time exercise — it's a review you run before every significant release, and a habit you build into every PR.

The teams that get this right aren't necessarily the ones with dedicated security engineers. They're the ones where every developer treats security as part of their job description.

That's the only way it works at scale.


Something missing from this guide? A pattern you've seen exploited in production? Drop it in the comments — this document should keep evolving.

Top comments (0)