Tutorial

Ship sites that are actually secure

The 9 web-security topics every developer should understand — what each attack looks like, how it happens, and the one-line fix that stops it cold.

The 9 threats you'll actually see

Web security sounds scary, but most attacks fall into a handful of categories — and most fixes are a single line of code. Here's the map:

No HTTPS

Data sent in plain text can be read by anyone on the network.

XSS

Attackers inject scripts that run in your users' browsers.

SQL Injection

Attackers rewrite your database queries from a form field.

Weak Passwords

Stored plain-text or hashed with MD5 — both easily cracked.

CSRF

A malicious site submits forms to yours while the user is logged in.

Data Exposure

API keys or secrets accidentally committed to a public repo.

Broken Auth

Session tokens leak, sessions don't expire, no 2FA.

Outdated Libs

Known vulnerabilities in packages you haven't updated in years.

Missing Headers

No CSP, no HSTS — defaults leave your site exposed.

1

Always use HTTPS

HTTPS encrypts traffic between the browser and your server. Without it, anyone on the coffee-shop WiFi can read passwords and cookies in plain text.

The good news: it's free. Every modern host (Netlify, Vercel, GitHub Pages, Cloudflare) gives you HTTPS automatically. If your deploy doesn't have a padlock in the browser, stop and fix it before launching.

Force HTTPS: Add this header and attackers can't downgrade users to HTTP:
Strict-Transport-Security: max-age=31536000; includeSubDomains
2

Validate inputs — twice

Never trust anything a user types. Validate on the client (for UX) AND on the server (for security). Attackers bypass client validation trivially.

<!-- Client-side: HTML5 constraints -->
<input type="email" required maxlength="120" pattern="[^@]+@[^@]+">
<input type="number" min="0" max="150">

Server-side, use an allowlist (what is allowed) over a denylist (what isn't). Reject anything that doesn't match — never try to "clean" bad input.

Rule of thumb: If it looks like code (contains <, {, --, $), your input validator should probably reject it unless you explicitly allow it.
3

Prevent XSS — escape user content

Cross-Site Scripting happens when you stick user input into your HTML without escaping. The attacker submits <script>steal()</script> and it runs in every visitor's browser.

Vulnerable (innerHTML) box.innerHTML = userName;
// <img src=x onerror=alert(1)>
Safe (textContent) box.textContent = userName;
// renders as plain text

In templates (React, Vue, Svelte, Jinja, ERB) — use the framework's escape, not raw HTML insertion. React does it automatically. Beware of dangerouslySetInnerHTML.

4

Prevent SQL injection — use parameterized queries

If you build SQL by string-concatenating user input, an attacker types ' OR '1'='1 and gets everyone's data.

Vulnerable db.query("SELECT * FROM users WHERE email='" + email + "'");
Safe (parameterized) db.query("SELECT * FROM users WHERE email = ?", [email]);

Every modern database driver supports parameterized queries (? placeholders, prepared statements, or an ORM). The golden rule: user data goes in arguments, never in the SQL string.

5

Store passwords correctly

Never store plain-text passwords. Never use MD5 or SHA-1 — they're too fast, meaning attackers can try billions per second.

Use a slow, salted hash: bcrypt, argon2, or scrypt. Every backend language has a library:

// Node.js with bcrypt
const hash = await bcrypt.hash(password, 12);  // store this
const match = await bcrypt.compare(input, hash); // verify on login
Even better: don't store passwords at all. Offer "Sign in with Google / GitHub" (OAuth) or magic-link email login — fewer credentials to protect.
6

CSRF — verify the request came from your site

Cross-Site Request Forgery: a malicious site tricks your logged-in user's browser into submitting a form to your site. Their session cookie goes with it, so your server thinks the user meant to do it.

Fix: include a CSRF token — a random value the attacker's site can't guess — in every state-changing form:

<form method="POST" action="/transfer">
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
  <!-- other fields -->
</form>

On cookies, set SameSite=Lax or Strict. That alone blocks most CSRF attacks in modern browsers.

7

Never commit secrets to Git

API keys, database passwords, JWT secrets — they end up on GitHub's public index within minutes of being pushed. Bots scan for them 24/7.

Vulnerable const API_KEY = "sk_live_abc123...";
Safe const API_KEY = process.env.API_KEY;
Already committed a secret? Rotate it immediately — it's effectively public. GitHub's guide to removing it only hides it; the key must be changed.
8

Add security headers

A few HTTP response headers block whole categories of attacks for free. Set them once and forget.

# Forces HTTPS for a year
Strict-Transport-Security: max-age=31536000; includeSubDomains

# Stops MIME-type sniffing
X-Content-Type-Options: nosniff

# Blocks your site being embedded in iframes (clickjacking)
X-Frame-Options: SAMEORIGIN

# Locks down what scripts/styles/images can load
Content-Security-Policy: default-src 'self'; img-src 'self' data: https:;

# Hides where users came from
Referrer-Policy: strict-origin-when-cross-origin

Check your site's score at securityheaders.com — aim for an A or higher.

9

Keep dependencies updated

Every npm install pulls in hundreds of packages — any one of them can ship a vulnerability. The fix is routine patching.

# Find known vulnerabilities
npm audit

# Auto-fix what can be fixed safely
npm audit fix

# See outdated packages
npm outdated

GitHub's Dependabot (free) opens a PR every time one of your dependencies has a security patch. Turn it on: repo → Settings → Security.

Pre-launch security checklist

Before shipping, run through this list. 30 minutes of prevention saves months of incident response.

Go deeper

Security is a habit, not a feature.

Every one of these fixes is a single line or config change. The hard part isn't writing them — it's remembering to add them before launch instead of after a breach.