OWASP Top 10 2021 for Go APIs (with Hands-On Examples)

OWASP Top 10 2021 is the “greatest hits” list of web app risks. For Go backends and APIs, each item shows up in very specific, repeatable ways.

I also maintain a hands-on lab repo you can play with as you read:

👉 Code lab: go-api-security – “OWASP TOP 10 With GO”

This article is the map: it walks through each OWASP 2021 category in Go terms and points to the repo as the playground.

OWASP Top 10 2021 categories:

  1. A01: Broken Access Control
  2. A02: Cryptographic Failures
  3. A03: Injection
  4. A04: Insecure Design
  5. A05: Security Misconfiguration
  6. A06: Vulnerable and Outdated Components
  7. A07: Identification and Authentication Failures
  8. A08: Software and Data Integrity Failures
  9. A09: Security Logging and Monitoring Failures
  10. A10: Server-Side Request Forgery

A01: Broken Access Control (Go Edition)

What it is:
Users can do things they shouldn’t: read others’ data, act as admins, bypass checks.

Typical Go smells

  • Handlers trust IDs in URL instead of checking ownership.
  • Auth is in middleware “most of the time” but some routes skip it.
  • Multi-tenant app: org_id is passed from client and never validated.

Vulnerable pattern

// GET /users/{id}
func getUser(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")          // from URL
    // current user is authenticated as userID in context but we ignore it
    user, _ := dbGetUserByID(r.Context(), id)
    json.NewEncoder(w).Encode(user)      // ❌ any user can fetch any ID
}

Safer pattern

type AuthedUser struct {
    ID    string
    OrgID string
    Role  string
}

func currentUser(r *http.Request) *AuthedUser {
    u, _ := r.Context().Value(userKey{}).(*AuthedUser)
    return u
}

func getUser(w http.ResponseWriter, r *http.Request) {
    u := currentUser(r)
    if u == nil {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }

    id := chi.URLParam(r, "id")

    user, err := dbGetUserByID(r.Context(), id)
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    if user.OrgID != u.OrgID && u.Role != "admin" {
        http.Error(w, "forbidden", http.StatusForbidden)
        return
    }

    json.NewEncoder(w).Encode(user)
}

Checklist for Go devs

  • Use auth middleware that loads the user into context.Context.
  • Always derive permissions from server-side identity, not from client-supplied IDs.
  • Enforce tenant boundaries (OrgID, AccountID) on every data access.
  • Use deny by default routing: sensitive routes must opt in to access, not opt out.

👉 Code: go-api-security has runnable access control examples to experiment with.


A02: Cryptographic Failures

What it is: Bad or missing crypto: weak tokens, broken password hashing, unencrypted data in transit or at rest.

Typical Go smells

  • Using math/rand for API keys or password reset tokens.
  • Homegrown password hashing (sha256(password)) instead of bcrypt/argon2.
  • Skipping TLS verification in http.Client or accepting any cert.

Vulnerable pattern

// Token that looks random but is predictable
func insecureToken() string {
    // math/rand is predictable if seed is known
    return fmt.Sprintf("%d", rand.Int63()) // ❌ not cryptographically secure
}

Safer pattern – random tokens

import "crypto/rand"
import "encoding/hex"

func secureToken(nBytes int) (string, error) {
    b := make([]byte, nBytes)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return hex.EncodeToString(b), nil
}

Safer pattern – password hashing

import "golang.org/x/crypto/bcrypt"

func hashPassword(pw string) (string, error) {
    hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
    return string(hash), err
}

func checkPassword(hash, pw string) error {
    return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw))
}

Checklist for Go devs

  • Use crypto/* packages, not math/rand or DIY crypto.
  • Hash passwords with bcrypt or argon2id, never raw hash functions.
  • Require TLS for all external communication; avoid InsecureSkipVerify in tls.Config.

👉 Code: the repo includes concrete examples and exercises for password storage and token generation.


A03: Injection (incl. SQL, Command Injection, XSS basics)

What it is: Untrusted data gets interpreted as code: SQL, shell, template expressions, etc. Classic XSS now sits under this umbrella.

Typical Go smells

  • Building SQL statements with fmt.Sprintf and string concatenation.
  • Passing user input into exec.Command("sh", "-c", "...").
  • Using text/template instead of html/template in HTML responses.

Vulnerable SQL pattern

func searchUsers(ctx context.Context, q string) ([]User, error) {
    // ❌ user input directly in SQL string
    sql := fmt.Sprintf(`SELECT id, email FROM users WHERE email LIKE '%%%s%%'`, q)
    rows, err := db.QueryContext(ctx, sql)
    // ...
}

Safer SQL pattern (parameterized)

func searchUsers(ctx context.Context, q string) ([]User, error) {
    rows, err := db.QueryContext(ctx,
        `SELECT id, email FROM users WHERE email LIKE ?`, "%"+q+"%",
    )
    // ...
}

Vulnerable command pattern

func runTool(input string) error {
    // ❌ shell expands input, potential command injection
    cmd := exec.Command("sh", "-c", "tool "+input)
    return cmd.Run()
}

Safer command pattern

func runTool(arg string) error {
    // ✅ no shell, arguments treated as literal
    cmd := exec.Command("tool", arg)
    return cmd.Run()
}

For XSS in Go templates, stick to html/template and avoid template.HTML on user input (I have a separate “XSS with Go” write-up that goes deep on this).

Checklist

  • Always use parameterized queries (? placeholders, no string concatenation).
  • Avoid sh -c in exec.Command; pass args as separate parameters.
  • Use html/template for HTML, never text/template for web views.
  • Treat any raw HTML or script from users as hostile and sanitize it.

👉 Code: see injection-focused examples in go-api-security and pair them with the XSS article.


A04: Insecure Design

What it is: The design itself is unsafe even if the code has no obvious bugs: missing rate limits, risky “magic links”, insecure workflows.

Go-shaped examples

  • Login endpoint allows unlimited attempts, no account lockout.
  • Password reset magic links never expire and are reusable.
  • Admin actions don’t require elevated checks or audit logging.

In earlier posts I walked through Threat Sketches and lightweight threat modeling for tiny teams. This is where those practices start paying off.

Example: login without rate limiting

func login(w http.ResponseWriter, r *http.Request) {
    // Perfectly “correct” code, but no throttling / lockout.
}

More secure design sketch

  • Rate limit login per IP + per account.
  • Add small backoff on repeated failures.
  • Log failed logins (without passwords) and monitor patterns.

Design lives one level up from code. The book/ content in go-api-security walks through Insecure Design vs better design in Go form.

Checklist

  • For each critical feature (login, password reset, invoice sharing), ask:

    • “How could this be abused at scale?”
    • “What happens if someone automates this 10k times?”
  • Add rate limiting, expiries, and revocation to designs.

  • Use the 10-minute Threat Sketch habit for sec:high stories.


A05: Security Misconfiguration

What it is: Settings and defaults that leave the app exposed: debug endpoints, open admin interfaces, sloppy CORS.

Typical Go smells

  • Exposing /debug/pprof or /debug/vars on the public internet.
  • Default http.Server with no timeouts or limits.
  • Access-Control-Allow-Origin: * with credentials.
  • Frameworks running in debug mode in production.

Vulnerable pattern

func main() {
    mux := http.NewServeMux()
    mux.Handle("/", appHandler)

    // ❌ pprof exposed on same listener
    mux.Handle("/debug/pprof/", http.DefaultServeMux)

    http.ListenAndServe(":8080", mux)
}

Safer pattern

func main() {
    appMux := http.NewServeMux()
    appMux.Handle("/", appHandler)

    srv := &http.Server{
        Addr:         ":8443",
        Handler:      appMux,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }
    log.Fatal(srv.ListenAndServeTLS("cert.pem", "key.pem"))
}

Expose pprof/metrics:

  • On a separate listener bound to localhost or internal network.
  • Or behind auth / VPN / IP allowlist.

Checklist

  • Turn off debug features in production (pprof, default expvar, framework debug mode).
  • Configure http.Server timeouts and reasonable body size limits.
  • Use explicit, minimal CORS configuration; avoid wildcard with cookies.
  • Protect admin/UIs with auth and, if possible, separate network paths.

👉 Code: the repo’s capstone script (capstone_e2e.sh) glues everything together; it’s a nice place to think about hardening misconfigurations end-to-end.


A06: Vulnerable and Outdated Components

What it is: Using libraries, frameworks, or tools with known vulnerabilities.

Go ships some useful tooling here.

  • govulncheck – the official vulnerability scanner that looks at your modules and flags reachable vulns.
  • gosec – static analysis for Go, scanning source for security issues.

Example: running govulncheck on the repo

go install golang.org/x/vuln/cmd/govulncheck@latest
cd go-api-security
govulncheck ./...

govulncheck uses the Go vulnerability database to report known CVEs that affect your dependencies.

Checklist

  • Check go.mod and go.sum into version control.
  • Add govulncheck to CI for ./....
  • Patch dependencies on a regular cadence (weekly/biweekly).
  • Pin base Docker images and monitor them for CVEs.
  • Don’t blindly update: review changelogs and rerun tests.

👉 Code: clone go-api-security and run govulncheck and gosec against it to see what these tools look like in practice.


A07: Identification & Authentication Failures

What it is: Login, session, or token problems: weak flows, bypasses, or missing checks.

Typical Go smells

  • Parsing a JWT’s claims but never verifying the signature.
  • Accepting alg: none or wrong signing algorithm.
  • Password reset tokens never expire and are reused.
  • No rate limiting on login or 2FA.

Vulnerable JWT pattern

func currentUserFromJWT(tokenStr string) (*UserClaims, error) {
    token, _, err := new(jwt.Parser).ParseUnverified(tokenStr, &UserClaims{})
    if err != nil {
        return nil, err
    }
    claims, _ := token.Claims.(*UserClaims)
    return claims, nil // ❌ never verify signature
}

Safer JWT pattern

func parseAndVerifyJWT(tokenStr string) (*UserClaims, error) {
    token, err := jwt.ParseWithClaims(tokenStr, &UserClaims{}, func(t *jwt.Token) (any, error) {
        // Enforce expected signing method
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method")
        }
        return []byte(hmacSecret), nil
    })
    if err != nil || !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }
    claims, ok := token.Claims.(*UserClaims)
    if !ok {
        return nil, fmt.Errorf("bad claims")
    }
    return claims, nil
}

Checklist

  • Use well-tested auth libraries and validate:

    • Algorithm, issuer, audience, expiry, not-before.
  • Rate limit login and 2FA attempts.

  • Rotate session IDs / tokens on login and privilege change.

  • Make password reset tokens single-use and short-lived.

👉 Code: the auth/login examples in the repo are a natural place to wire these checks in and experiment.


A08: Software and Data Integrity Failures

What it is: Trusting unverified code or data: compromised pipeline, malicious dependencies, untrusted serialized objects.

Go-shaped examples

  • CI pipeline that runs go install github.com/some/random/tool@latest without pinning or review.
  • Application downloading plugins or configs from arbitrary URLs at startup.
  • Accepting config or policy data from the client and using it directly for authorization decisions.

Vulnerable pattern

# In CI: always install latest from internet
steps:
  - run: go install github.com/example/tool@latest # ❌ if account is compromised, you pull malicious code

Safer pattern

  • Pin versions in CI:

    • go install github.com/example/tool@v1.2.3
  • Review and update explicitly instead of “latest”.

Checklist

  • Pin versions for build tools and modules.
  • Avoid dynamic loading of code from untrusted sources.
  • Don’t let clients control security-critical config; keep policies on the server.
  • Use signed releases/checksums for important artifacts.

👉 Code: the capstone_e2e.sh and CI examples around go-api-security are a good thought exercise for software supply chain and integrity.


A09: Security Logging & Monitoring Failures

What it is: No (or bad) logging and monitoring so you can’t detect or investigate attacks.

Typical Go smells

  • Using only log.Println with no request IDs or structure.
  • Logging secrets (Authorization headers, tokens, passwords).
  • No logs for access control failures, only 500s.

Simple logging middleware

type keyRequestID struct{}

func withRequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := uuid.NewString()
        ctx := context.WithValue(r.Context(), keyRequestID{}, id)
        r = r.WithContext(ctx)

        log.Printf("request_id=%s method=%s path=%s", id, r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

You can upgrade to structured loggers (zap, zerolog, logrus) but even this is better than nothing.

Checklist

  • Log at least:

    • Auth events (login success/fail, password reset).
    • Access control failures (403).
    • Significant data changes (delete account, change email, privilege changes).
  • Never log passwords, full tokens, or secrets.

  • Centralize logs and build basic alerts (spikes in 401/403, unusual IP ranges).

👉 Code: wire a logging middleware into the HTTP stack in go-api-security and watch how much easier incidents become to reason about.


A10: Server-Side Request Forgery (SSRF)

What it is: The app makes HTTP requests based on user input; an attacker makes it call internal services or metadata endpoints.

Typical Go smells

  • “URL fetcher” or “webhook tester” endpoints that just do http.Get(userURL).
  • PDF/image “proxy” that loads from arbitrary URLs under your server’s network identity.

Vulnerable pattern

func fetchURL(w http.ResponseWriter, r *http.Request) {
    target := r.URL.Query().Get("url") // user-controlled
    resp, err := http.Get(target)      // ❌ can hit 169.254.169.254, internal IPs, etc.
    if err != nil {
        http.Error(w, "fetch error", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()
    io.Copy(w, resp.Body)
}

Safer pattern (allowlist + custom transport)

var allowedHosts = map[string]bool{
    "example.com":     true,
    "api.partner.com": true,
}

func isAllowed(u *url.URL) bool {
    return allowedHosts[u.Hostname()]
}

func safeClient() *http.Client {
    tr := &http.Transport{
        // Optionally restrict IP ranges here (block 10.0.0.0/8, 169.254.169.254, etc.)
        // via DialContext wrapper.
    }
    return &http.Client{Transport: tr, Timeout: 5 * time.Second}
}

func fetchURL(w http.ResponseWriter, r *http.Request) {
    raw := r.URL.Query().Get("url")
    u, err := url.Parse(raw)
    if err != nil || u.Scheme != "https" {
        http.Error(w, "bad url", http.StatusBadRequest)
        return
    }
    if !isAllowed(u) {
        http.Error(w, "forbidden", http.StatusForbidden)
        return
    }

    client := safeClient()
    resp, err := client.Get(u.String())
    if err != nil {
        http.Error(w, "fetch error", http.StatusBadGateway)
        return
    }
    defer resp.Body.Close()

    io.Copy(w, io.LimitReader(resp.Body, 1<<20)) // 1MB limit
}

Checklist

  • Never call http.Get on raw user input without validation.
  • Restrict outbound hosts via allowlists or tight DNS/IP controls.
  • Add timeouts and response body size limits when proxying.
  • Avoid exposing generic “fetch any URL” features; prefer specific integrations.

👉 Code: there’s a natural SSRF playground in the “URL fetcher” style examples in go-api-security.


Tooling: Go-Native Help for OWASP Top 10

It’s easy to wire simple security tooling into CI for Go projects:

  • gosec – SAST for Go that scans code for security problems.
  • govulncheck – the official Go vulnerability checker using the Go vuln DB.

Example GitHub Actions snippet:

name: security-checks

on: [push, pull_request]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'

      - name: Run govulncheck
        run: |
          go install golang.org/x/vuln/cmd/govulncheck@latest
          govulncheck ./...

      - name: Run gosec
        run: |
          go install github.com/securego/gosec/v2/cmd/gosec@latest
          gosec ./...

Run that against go-api-security or against your own services to get quick feedback.


OWASP Top 10 Checklist for Go APIs

Here’s a concise, Go-flavored checklist summarizing everything:

OWASP Top 10 for Go APIs – Cheat Sheet

A01 Broken Access Control

  • Every handler enforces auth/authorization based on server-side identity, not client IDs.
  • Tenant/org boundaries are checked on every data access.
  • Sensitive routes are protected via middleware (deny-by-default).

A02 Cryptographic Failures

  • Only crypto/* packages used for tokens/crypto, never math/rand.
  • Passwords hashed with bcrypt/argon2, never plain or DIY hashes.
  • TLS is enforced; no InsecureSkipVerify in production.

A03 Injection

  • All SQL queries are parameterized (no string concatenation).
  • No exec.Command("sh", "-c", ...) on user input.
  • HTML rendering uses html/template with no raw template.HTML from users.

A04 Insecure Design

  • Critical flows (login, reset, sharing links) have rate limits, expiries, and revocation.
  • A 5–10 minute threat sketch exists for sec:high stories.

A05 Security Misconfiguration

  • Debug endpoints (/debug/pprof, /metrics) are not exposed publicly.
  • http.Server timeouts and limits are configured.
  • CORS configured minimally; no * with credentials.

A06 Vulnerable & Outdated Components

  • govulncheck runs regularly in CI.
  • Dependencies and base images are pinned and updated on a schedule.

A07 Identification & Authentication Failures

  • JWTs are fully verified (alg, issuer, audience, expiry).
  • Login has brute-force protections and secure password reset.
  • Tokens/sessions are rotated on login and privilege change.

A08 Software & Data Integrity Failures

  • Build tools and modules are version-pinned (no “@latest” in CI).
  • No dynamic code loading from untrusted URLs.
  • Clients don’t control security-critical config.

A09 Security Logging & Monitoring Failures

  • Requests have IDs; important auth/permission events are logged.
  • Secrets/tokens are never logged.
  • There are basic alerts on auth failures and anomalies.

A10 Server-Side Request Forgery

  • No generic http.Get on user-supplied URLs.
  • Outbound requests restricted by allowlist or tight IP/DNS policy.
  • Proxies have timeouts and size limits.

If you want to turn this from theory into muscle memory, clone go-api-security, run the examples locally, and start layering these habits into your real Go services.