Security Basics Guide
The habits and defences every developer needs β threat modeling, password storage, access control, secrets, HTTPS, the OWASP Top 10, input validation, supply-chain safety, secure deployment and incident response. Every code block has a Copy button.
Copy button on each code block Β· β Beginner β Intermediate β Advanced01 Introduction β Think Like an Attacker Beginner
Security is not a library you install or a checkbox at the end of a project. It is the set of decisions that keep other people from doing things you did not intend with your system. Most breaches are not exotic β they are missed authorization checks, leaked secrets, outdated dependencies and unvalidated input.
The CIA triad
Core principles
- Least privilege β every user, process and token gets the minimum access it needs, and only for as long as it needs it
- Defense in depth β assume any single layer will fail; add another
- Fail securely β when something breaks, deny access rather than allow it
- Never trust input β headers, cookies, query strings, JSON bodies and file names are all attacker-controlled
- Keep it simple β every extra feature, endpoint and permission is another thing to secure
- Security by design β decide who can do what before writing the endpoint, not after
Threat modeling in five minutes
- What are we building? β draw the data flow: browser, API, database, third-party services
- What can go wrong? β walk each trust boundary and ask what an attacker could send, read or replay
- What are we going to do? β pick a mitigation for each credible threat
- Did we do a good job? β test it, review it, and repeat when the design changes
STRIDE is a handy checklist for step two: Spoofing identity, Tampering with data, Repudiation of actions, Information disclosure, Denial of service, and Elevation of privilege.
.env files, default admin logins and forgotten staging servers within hours of them going live.02 Passwords, Hashing & Authentication Beginner
Never store passwords in plain text
Store only a slow, salted hash. Hashing is one-way: you verify a login by hashing the attempt and comparing, never by decrypting the stored value.
// Node.js β argon2 (recommended)
import argon2 from "argon2";
const hash = await argon2.hash(password);
// "$argon2id$v=19$m=65536,t=3,p=4$..."
const ok = await argon2.verify(hash, attempt);
// Node.js β bcrypt alternative
import bcrypt from "bcrypt";
const hash = await bcrypt.hash(password, 12);
const ok = await bcrypt.compare(attempt, hash);
Password policy that actually helps
- Minimum length 8β12, allow long passphrases (64+ characters)
- No forced composition rules β they produce
Password1!, not strength - Block breached passwords β check against Have I Been Pwned's k-anonymity API
- Rate-limit and lock out login attempts, with exponential backoff
- Offer MFA β TOTP or passkeys; SMS is the weakest option
- Never email a password β send a single-use reset link with a short expiry
Sessions vs JWTs
Sessions store state on the server and send an opaque ID in a cookie. JWTs carry signed claims in the token itself and are stateless β but stateless means you cannot easily revoke them.
// Session cookie β safe defaults
res.cookie("sid", sessionId, {
httpOnly: true, // JavaScript cannot read it
secure: true, // HTTPS only
sameSite: "lax", // blocks most CSRF
maxAge: 1000 * 60 * 60 * 24,
path: "/"
});
localStorage: Any XSS bug can read them. Prefer an httpOnly cookie, keep access tokens short-lived, and use refresh-token rotation with revocation. Never accept alg: "none", and always verify the signature and issuer.03 Authorization & Access Control Intermediate
Authentication answers βwho are you?β. Authorization answers βwhat are you allowed to do?β. Broken access control is consistently the #1 item on the OWASP Top 10 β and it is almost always a missing server-side check.
Models
- RBAC β roles such as
admin,editor,viewer; simple and predictable - ABAC β rules based on attributes (owner, department, time, resource state)
- ReBAC β relationships such as βowner ofβ, βmember ofβ, βshared withβ
Deny by default
Every route should require an explicit grant. If a check is missing, the request should fail β not succeed.
// Express β authorize after authenticating
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
}
app.delete("/api/users/:id", requireRole("admin"), deleteUser);
IDOR β the classic bug
An Insecure Direct Object Reference happens when the app fetches a record by an ID from the request without checking ownership.
// β Any logged-in user can read any invoice
const invoice = await db.invoice.findById(req.params.id);
// β
Scope the query to the authenticated user
const invoice = await db.invoice.findOne({
_id: req.params.id,
userId: req.user.id
});
if (!invoice) return res.status(404).json({ error: "Not found" });
curl or DevTools. Check permissions on the server for every request.Multi-tenant rules
- Always filter by
tenantId/orgIdin the query itself, not after fetching - Check permissions on the specific record, not just the route
- Use unguessable IDs (UUIDv4/ULID) as defence in depth, never as the only control
- Return 404 instead of 403 when revealing existence is itself a leak
- Log access to sensitive records for audit
can(user, "delete", post)) instead of scattering if statements. It is easier to test, review and keep consistent.04 Secrets & Configuration Beginner
A secret is anything that grants access: API keys, database passwords, JWT signing keys, private certificates, OAuth client secrets. Secrets belong in the environment or a secret manager β never in source code.
Local development with .env
# .env β never commit this file
DATABASE_URL=postgres://app:password@localhost:5432/app
JWT_SECRET=replace-with-a-long-random-value
STRIPE_SECRET_KEY=sk_test_...
NODE_ENV=development
# .gitignore
.env
.env.*
!.env.example
node_modules/
*.pem
*.key
# .env.example β commit this as documentation
DATABASE_URL=
JWT_SECRET=
STRIPE_SECRET_KEY=
Reading secrets safely in Node.js
import "dotenv/config";
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET is required");
}
// Fail fast on missing configuration β do not boot half-configured
VITE_ variable or a mobile app bundle can be extracted in minutes. Only publish values that are safe for anyone to see.If a secret leaks
- Rotate it immediately β revoke the old key and issue a new one. Do this before cleaning history.
- Check usage β audit logs for unexpected calls or spend.
- Remove it from history β
git filter-repoor the BFG Repo-Cleaner; a normal delete commit still leaves the value in history. - Add prevention β pre-commit secret scanning, CI checks, and a
.gitignoreentry.
# Scan the working tree and full history for common secret patterns
gitleaks detect --source . --log-opts="--all"
# Rewrite history to drop a leaked file (then force-push carefully)
git filter-repo --path .env --invert-paths
05 HTTPS, TLS & Cookies Intermediate
HTTPS encrypts traffic between the browser and your server and proves you are talking to the right server. Without it, passwords, session cookies and API tokens travel in clear text on every coffee-shop Wi-Fi.
What good looks like
- TLS 1.2+ only β disable SSL and TLS 1.0/1.1
- Redirect HTTP β HTTPS with a 301, except ACME challenges
- HSTS β tell browsers to refuse plain HTTP for your domain
- Valid certificate β Let's Encrypt is free and auto-renews
- No mixed content β every script, image and font must also be HTTPS
# Redirect all HTTP traffic to HTTPS
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
# HSTS: one year, include subdomains, allow preload
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
Cookie flags that matter
Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400
curl, scripts or attackers. Never rely on CORS to protect an endpoint β authenticate and authorize instead.ssllabs.com/ssltest and your headers at securityheaders.com. Both give a clear grade and explain every deduction.06 OWASP Top 10 in Practice Intermediate
The OWASP Top 10 is a consensus list of the most critical web application risks. Learn the categories, then learn the fix for each one.
XSS β cross-site scripting
XSS happens when untrusted data is rendered as HTML or JavaScript. It comes in three flavours: stored (saved in the database), reflected (bounced back in a response) and DOM-based (written by client-side code).
// β Untrusted string interpreted as HTML
element.innerHTML = userComment;
// β
Plain text β the browser never parses tags
element.textContent = userComment;
// β
If HTML is genuinely required, sanitise it first
import DOMPurify from "dompurify";
element.innerHTML = DOMPurify.sanitize(userComment);
innerHTML, eval and document.write with untrusted data.SQL injection
// β String concatenation β attacker controls the query
db.query(`SELECT * FROM users WHERE email = '${email}'`);
// β
Parameterised query β data stays data
db.query("SELECT * FROM users WHERE email = $1", [email]);
// β
Or use an ORM / query builder
await User.findOne({ where: { email } });
CSRF β cross-site request forgery
An attacker makes a logged-in user's browser send a state-changing request to your site. Defences: SameSite cookies, a CSRF token checked on every mutating request, and requiring re-authentication for sensitive actions.
// Express β CSRF token in a form
app.get("/transfer", (req, res) => {
res.render("transfer", { csrfToken: req.csrfToken() });
});
app.post("/transfer", csrfProtection, (req, res) => {
// token was validated by csrfProtection
});
SSRF β server-side request forgery
If your server fetches a URL supplied by the user, an attacker can make it reach internal services or cloud metadata endpoints. Allowlist destinations, block private IP ranges, disable redirects and never return raw responses.
// Cloud metadata endpoint β a classic SSRF target
http://169.254.169.254/latest/meta-data/iam/security-credentials/
// Defence: allow only expected hosts and schemes
const allowed = new Set(["api.example.com", "images.example.com"]);
07 Input Validation & Output Encoding Intermediate
Validation decides what data is allowed in. Encoding decides how data is written out. You need both β neither is a substitute for the other.
Validate with allowlists
- Allowlist what is expected; do not blocklist what looks bad
- Check type, length, range, format and required fields
- Validate on the server β client-side checks are only a UX nicety
- Use a schema library such as Zod, Yup, Joi or Ajv
- Reject unknown fields so mass-assignment cannot set
isAdmin
import { z } from "zod";
const Signup = z.object({
email: z.string().email().max(254),
password: z.string().min(12).max(128),
age: z.number().int().min(13).max(120).optional()
}).strict();
const data = Signup.parse(req.body);
// Throws on invalid input; unknown keys are rejected
Encode for the right context
// β Shell injection through exec()
exec(`convert ${filename} out.png`);
// β
No shell β arguments stay separate
execFile("convert", [filename, "out.png"]);
File uploads
- Check the real content type, not just the extension or the
Content-Typeheader - Rename files and store them outside the web root (or in object storage)
- Enforce size limits and image dimension limits
- Never execute uploaded files; serve them with a safe
Content-Disposition - Prevent path traversal β reject
..and absolute paths; resolve and verify the final path stays inside the upload directory
# Path traversal attempt
GET /download?file=../../../../etc/passwd
# Defence: basename + resolve + prefix check
const safe = path.basename(req.query.file);
const full = path.resolve(UPLOAD_DIR, safe);
if (!full.startsWith(path.resolve(UPLOAD_DIR) + path.sep)) {
return res.status(400).end();
}
08 Dependencies & Supply Chain Advanced
Most modern applications are mostly other people's code. A single compromised or outdated package can hand an attacker your build, your CI secrets or your users' data.
Everyday habits
- Commit the lockfile (
package-lock.json,pnpm-lock.yaml) and install withnpm ci - Audit regularly β
npm audit,pnpm audit, Dependabot or Renovate - Update deliberately β patch promptly, read changelogs for majors
- Remove unused packages β fewer dependencies, fewer surprises
- Prefer well-maintained libraries with recent releases and multiple maintainers
- Watch for typosquatting β
cross-envvscrossenv,lodashvslodahs
# Audit the dependency tree
npm audit
npm audit fix
# Install exactly what the lockfile says, ignoring scripts
npm ci --ignore-scripts
# See why a package is installed
npm ls package-name
curl β¦ | bash pattern runs whatever the server sends today: Download, read and pin the script instead. The same applies to unpinned GitHub Actions β pin third-party actions to a full commit SHA, not a moving tag.Scripts and browser integrity
<!-- Subresource Integrity: pin the exact file hash -->
<script
src="https://cdn.example.com/lib.min.js"
integrity="sha384-BASE64_HASH_HERE"
crossorigin="anonymous"></script>
Protect the pipeline
- Store CI secrets in the CI provider's secret store, never in the repo
- Restrict who can merge to
mainand who can publish packages - Require review for lockfile changes β they are code changes
- Generate an SBOM (
npm sbom, Syft) so you can answer βare we affected?β fast - Use
npm ci --ignore-scriptsin untrusted builds to stop install-time code execution
09 Secure Deployment & Headers Advanced
Secure code can still be defeated by a misconfigured server. Harden the edges: headers, CORS, rate limits, error handling, logging and backups.
Security headers
# A reasonable starter set (tune the CSP to your app)
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
X-Frame-Options: DENY
Express hardening
import helmet from "helmet";
import rateLimit from "express-rate-limit";
app.use(helmet());
app.disable("x-powered-by");
app.use("/api", rateLimit({
windowMs: 60_000,
limit: 100,
standardHeaders: "draft-7",
legacyHeaders: false
}));
app.use(express.json({ limit: "100kb" }));
Error handling and logging
- Never show stack traces or database errors to users β log them server-side instead
- Return generic messages for auth and internal errors
- Log security events β failed logins, permission denials, admin actions
- Never log secrets, tokens, passwords or full card numbers
- Redact PII and set a retention period
- Alert on anomalies β spikes in 401/403, error rates, unusual regions
Operational basics
- Run the database with a least-privilege user, not as the owner/superuser
- Disable directory listing and remove default pages, sample apps and debug endpoints
- Keep the OS, runtime and dependencies patched; automate updates where possible
- Use separate credentials and data for development, staging and production
- Encrypt backups and test restores β an untested backup is a hope, not a plan
- Set request size limits and timeouts to blunt denial-of-service attempts
DEBUG=true, source maps and admin panels left open are among the most common real-world exposures. Make βproductionβ the safe default and require explicit opt-in for diagnostics.securityheaders.com, observatory.mozilla.org, and nuclei for known misconfigurations. Fix the easy wins first.10 Incident Response & Cheat Sheet Advanced
When something goes wrong
- Identify β confirm the incident, scope what is affected, start a timeline and keep notes
- Contain β revoke keys, disable the vulnerable endpoint, isolate the host, block the attacker
- Eradicate β patch the root cause, remove backdoors and malicious packages
- Recover β restore from a known-good backup, rotate all potentially exposed credentials, monitor closely
- Learn β write a blameless post-mortem: what happened, what helped, what to change
Developer security checklist
- Passwords hashed with argon2id/bcrypt β never plain text or fast hashes
- MFA available; login and reset endpoints rate-limited
- Authorization checked on the server for every object and action
- All input validated; all output encoded for its context
- Parameterized queries and
execFileinstead of string-built commands - Secrets in env/secret manager;
.envgit-ignored and history scanned - HTTPS everywhere, HSTS enabled, cookies
HttpOnly; Secure; SameSite - Dependencies audited and lockfile committed; CI actions pinned
- Security headers, CORS allowlist, request size limits and rate limits
- Generic error messages, structured logs without secrets, alerts on anomalies
- Backups encrypted and restore-tested; least-privilege DB users
- A written plan for revoked keys and incident response