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
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 %>">
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
| 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');
});
});
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'] });
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);
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, andaudclaims
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
});
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');
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?
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);
});
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);
});
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');
});
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
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);
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
});
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 });
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
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);
// ...
});
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');
}
};
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'
});
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
]
});
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));
}
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
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
Audit regularly:
npm audit
npm audit --audit-level=high # Fail CI on high-severity issues
# For more comprehensive scanning
npx snyk test
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>
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 installcan execute arbitrary code viapreinstallhooks) - Use
socket.devorSnykfor 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
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
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)
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');
// 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;
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
If you've already committed a secret:
- Rotate the secret immediately — assume it's compromised
- Remove it from history with
git filter-repo(notgit filter-branch) - Force-push all affected branches
- 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
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;
}
}
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.lockcommitted and used in CI (npm ci) - [ ]
npm auditrun in CI pipeline - [ ] New dependencies reviewed before installation
- [ ] SRI hashes on CDN-loaded scripts
- [ ] GitHub Actions (and equivalent) pinned to commit SHAs
- [ ]
gitleaksor equivalent scanning in pre-commit hook and CI
Secrets Management
- [ ] No secrets in source code, config files, or environment variable logs
- [ ]
.envin.gitignore, no.envfiles 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)