Skip to content

CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection') - Go

Overview

SQL Injection in Go applications occurs when untrusted data is incorporated into SQL queries without proper parameterization. The standard library's database/sql package uses placeholders (? for most databases, $1, $2... for PostgreSQL) that separate query structure from data, but building the query with string concatenation or fmt.Sprintf throws that separation away. The Go ecosystem includes several database libraries and ORMs (GORM, sqlx, sqlc) that provide varying levels of protection, but manual query construction remains a common source of vulnerabilities.

Go's type system and explicit error handling catch some mistakes, but neither stops SQL injection. Dynamic query construction for complex filters, sorting, or table names can bypass the placeholder mechanism if it is not implemented carefully.

Unlike some languages where ORMs dominate, Go applications often use raw SQL with the standard library, making it important that developers understand parameterized queries. The language's simplicity means there are fewer "magic" protections, placing security responsibility on the developer to use APIs correctly.

Primary Defence: Use parameterized queries with database/sql placeholders (? or $1), or ORM query methods such as GORM's Where() that parameterize automatically. Never concatenate user input into SQL strings.

Common Vulnerable Patterns

String Concatenation with Raw SQL

// VULNERABLE - String concatenation allows SQL injection
import (
    "database/sql"
    "fmt"
    "net/http"
)

func getUserHandler(w http.ResponseWriter, r *http.Request) {
    userID := r.URL.Query().Get("id")

    // DANGEROUS: Direct string concatenation
    query := "SELECT * FROM users WHERE id = " + userID

    rows, err := db.Query(query)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    defer rows.Close()
    // ... process rows
}

// Attack example:
// GET /user?id=1 OR 1=1
// Query becomes: SELECT * FROM users WHERE id = 1 OR 1=1
// Result: Returns all users, bypassing authorization

Why this is vulnerable: String concatenation merges user input directly into the SQL command structure. Anything the attacker sends is parsed as part of the query, so an injected operator changes what the query does: the OR 1=1 above turns a single-row lookup into a dump of the whole users table, and the same opening carries UNION SELECT or ; DROP TABLE.

fmt.Sprintf() Query Building

// VULNERABLE - String formatting is equally dangerous
func searchUsers(w http.ResponseWriter, r *http.Request) {
    searchTerm := r.URL.Query().Get("name")

    // DANGEROUS: fmt.Sprintf inserts unescaped user input
    query := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", searchTerm)

    rows, err := db.Query(query)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    defer rows.Close()
    // ... process rows
}

// Attack example:
// GET /search?name=admin' OR '1'='1
// Query becomes: SELECT * FROM users WHERE name = 'admin' OR '1'='1'
// Result: Returns all users

Why this is vulnerable: fmt.Sprintf() performs string substitution and knows nothing about SQL metacharacters. Quotes, semicolons, and SQL keywords in user input become part of the query structure: the quote in admin' OR '1'='1 closes the string literal, and everything after it is parsed as query logic.

Dynamic Query Building Without Parameterization

// VULNERABLE - Building WHERE clauses dynamically
func filterUsers(w http.ResponseWriter, r *http.Request) {
    var conditions []string

    if name := r.URL.Query().Get("name"); name != "" {
        // DANGEROUS: No parameterization
        conditions = append(conditions, "name = '"+name+"'")
    }

    if email := r.URL.Query().Get("email"); email != "" {
        // DANGEROUS: Same vulnerability
        conditions = append(conditions, "email = '"+email+"'")
    }

    query := "SELECT * FROM users WHERE " + strings.Join(conditions, " AND ")
    rows, err := db.Query(query)
    // ... vulnerable query execution
}

// Attack example:
// GET /filter?name=admin'--
// Query becomes: SELECT * FROM users WHERE name = 'admin'--' AND email = ''
// Result: Comment removes rest of query, bypasses email filter

Why this is vulnerable: Every condition built by concatenation is its own injection point. A comment sequence (--, /**/) truncates the rest of the query, as the admin'-- above does to the email filter; the same opening also accepts an injected OR condition or a UNION SELECT.

String Concatenation in ORDER BY

// VULNERABLE - Dynamic sorting without validation
func listUsers(w http.ResponseWriter, r *http.Request) {
    sortBy := r.URL.Query().Get("sort") // user controls: "name", "email", etc.

    // DANGEROUS: Column name concatenation
    query := "SELECT * FROM users ORDER BY " + sortBy

    rows, err := db.Query(query)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    defer rows.Close()
    // ... process rows
}

// Attack example:
// GET /users?sort=name; DROP TABLE users--
// Query becomes: SELECT * FROM users ORDER BY name; DROP TABLE users--
// Result: Table deletion via SQL injection

Why this is vulnerable: Column names cannot be parameterized in SQL, forcing developers to concatenate identifiers. Without allowlist validation the concatenated text is arbitrary SQL: a stacked query (semicolon-separated statements, as in the ; DROP TABLE users-- above), a subquery, or a CASE expression that leaks data through response timing in a blind injection.

Secure Patterns

// SECURE - Use placeholders for all user input
package main

import (
    "database/sql"
    "encoding/json"
    "log"
    "net/http"

    _ "github.com/lib/pq" // PostgreSQL driver
)

var db *sql.DB

func getUserByID(w http.ResponseWriter, r *http.Request) {
    userID := r.URL.Query().Get("id")

    // SECURE - Use placeholder ($1 for PostgreSQL, ? for MySQL/SQLite)
    query := "SELECT id, name, email FROM users WHERE id = $1"

    var user struct {
        ID    int    `json:"id"`
        Name  string `json:"name"`
        Email string `json:"email"`
    }

    err := db.QueryRow(query, userID).Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        if err == sql.ErrNoRows {
            http.Error(w, "User not found", http.StatusNotFound)
            return
        }
        log.Printf("Database error: %v", err)
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func main() {
    var err error
    db, err = sql.Open("postgres", "postgres://user:pass@localhost/dbname?sslmode=require")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    http.HandleFunc("/user", getUserByID)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Why this works: Placeholder parameters ($1, $2 for PostgreSQL; ? for MySQL/SQLite) separate the query structure from data values. The database driver sends the query template and parameters separately over the wire protocol, and parameter values are treated as literals rather than executable SQL: 1 OR 1=1 arrives as a search value, not a logic condition.

Multiple Parameters with Query()

// SECURE - Multiple placeholders for complex queries
import (
    "database/sql"
    "net/http"
)

func searchUsers(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    email := r.URL.Query().Get("email")
    minAge := r.URL.Query().Get("min_age")

    // SECURE - All user inputs are parameterized
    query := `
        SELECT id, name, email, age
        FROM users
        WHERE name LIKE $1
          AND email LIKE $2
          AND age >= $3
    `

    rows, err := db.Query(query, "%"+name+"%", "%"+email+"%", minAge)
    if err != nil {
        log.Printf("Query error: %v", err)
        http.Error(w, "Search failed", http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        var u User
        if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age); err != nil {
            log.Printf("Scan error: %v", err)
            continue
        }
        users = append(users, u)
    }

    if err := rows.Err(); err != nil {
        log.Printf("Rows error: %v", err)
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(users)
}

Why this works: Each placeholder ($1, $2, $3) binds one parameter value, maintaining separation between query logic and data throughout multiple conditions. LIKE wildcards (%) are added in Go code before parameterization, making them part of the data value, not SQL syntax. The database processes parameters as typed literals, type-checking them against the column definitions, which adds a layer of validation on top of injection prevention.

Dynamic Conditions with Parameterized Queries

// SECURE - Build dynamic WHERE clauses with proper parameterization
import (
    "database/sql"
    "net/http"
    "strings"
)

func filterUsers(w http.ResponseWriter, r *http.Request) {
    var conditions []string
    var args []interface{}
    paramCount := 1

    if name := r.URL.Query().Get("name"); name != "" {
        conditions = append(conditions, fmt.Sprintf("name = $%d", paramCount))
        args = append(args, name)
        paramCount++
    }

    if email := r.URL.Query().Get("email"); email != "" {
        conditions = append(conditions, fmt.Sprintf("email = $%d", paramCount))
        args = append(args, email)
        paramCount++
    }

    if role := r.URL.Query().Get("role"); role != "" {
        conditions = append(conditions, fmt.Sprintf("role = $%d", paramCount))
        args = append(args, role)
        paramCount++
    }

    // Default condition if no filters provided
    if len(conditions) == 0 {
        conditions = append(conditions, "1=1")
    }

    // SECURE - Parameterized WHERE clause
    query := "SELECT id, name, email FROM users WHERE " + strings.Join(conditions, " AND ")

    rows, err := db.Query(query, args...)
    if err != nil {
        log.Printf("Query error: %v", err)
        http.Error(w, "Filter failed", http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    // Process results...
}

Why this works: The query structure (WHERE clause with placeholders $1, $2, $3) is built from trusted code, while all user-supplied data goes into the args slice for parameterization. By incrementing paramCount and using fmt.Sprintf only for placeholder numbers (not user data), the dynamic query remains safe. The args... variadic expansion passes parameters to db.Query() in order, maintaining the parameterization contract even with conditional logic.

Allowlist Validation for Identifiers (ORDER BY, Column Names)

// SECURE - Allowlist validation for non-parameterizable elements
import (
    "database/sql"
    "fmt"
    "net/http"
)

var allowedSortColumns = map[string]bool{
    "id":         true,
    "name":       true,
    "email":      true,
    "created_at": true,
}

var allowedSortOrders = map[string]bool{
    "ASC":  true,
    "DESC": true,
}

func listUsersSorted(w http.ResponseWriter, r *http.Request) {
    sortBy := r.URL.Query().Get("sort")
    order := r.URL.Query().Get("order")

    // Default values
    if sortBy == "" {
        sortBy = "id"
    }
    if order == "" {
        order = "ASC"
    }

    // SECURE - Allowlist validation (identifiers cannot be parameterized)
    if !allowedSortColumns[sortBy] {
        http.Error(w, "Invalid sort column", http.StatusBadRequest)
        return
    }

    if !allowedSortOrders[order] {
        http.Error(w, "Invalid sort order", http.StatusBadRequest)
        return
    }

    // SECURE - Validated identifiers + parameterized search
    searchTerm := r.URL.Query().Get("search")
    query := fmt.Sprintf(
        "SELECT id, name, email FROM users WHERE name LIKE $1 ORDER BY %s %s",
        sortBy, // validated against allowlist
        order,  // validated against allowlist
    )

    rows, err := db.Query(query, "%"+searchTerm+"%")
    if err != nil {
        log.Printf("Query error: %v", err)
        http.Error(w, "Query failed", http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    // Process results...
}

Why this works: SQL identifiers (table names, column names, ORDER BY, keywords) cannot be parameterized because they define query structure, not data values. Allowlist validation means only a pre-approved identifier reaches the query: a value that is not a key in the map is rejected before fmt.Sprintf runs, so no attacker-controlled keyword, semicolon, or operator can reach the ORDER BY. The pattern combines parameterization for the data with allowlisting for the identifiers, covering both.

Framework-Specific Guidance

// SECURE - GORM with proper query methods
package main

import (
    "encoding/json"
    "log"
    "net/http"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

type User struct {
    ID    uint `gorm:"primarykey"`
    Name  string
    Email string
    Age   int
}

var gormDB *gorm.DB

func searchUsersGORM(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    minAge := r.URL.Query().Get("min_age")

    var users []User

    // SECURE - GORM auto-parameterizes with Where()
    query := gormDB.Where("name LIKE ?", "%"+name+"%")

    if minAge != "" {
        query = query.Where("age >= ?", minAge)
    }

    result := query.Find(&users)
    if result.Error != nil {
        log.Printf("GORM error: %v", result.Error)
        http.Error(w, "Search failed", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(users)
}

// VULNERABLE - Raw SQL without parameterization in GORM
func vulnerableGORM(w http.ResponseWriter, r *http.Request) {
    userID := r.URL.Query().Get("id")

    var user User
    // DANGEROUS: db.Raw with string concatenation
    query := "SELECT * FROM users WHERE id = " + userID
    gormDB.Raw(query).Scan(&user) // VULNERABLE
}

// SECURE - Raw SQL with parameters in GORM
func secureGORMRaw(w http.ResponseWriter, r *http.Request) {
    userID := r.URL.Query().Get("id")

    var user User
    // SECURE - db.Raw with placeholders
    gormDB.Raw("SELECT * FROM users WHERE id = ?", userID).Scan(&user)

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func main() {
    var err error
    dsn := "host=localhost user=postgres password=pass dbname=testdb port=5432 sslmode=require"
    gormDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal(err)
    }

    gormDB.AutoMigrate(&User{})

    http.HandleFunc("/search", searchUsersGORM)
    http.HandleFunc("/user/raw", secureGORMRaw)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Why this works: GORM's Where(), Find(), First(), and other query builder methods automatically parameterize values using placeholders internally. When chaining conditions, each Where() call adds a parameterized clause, and struct fields and method arguments become parameters rather than query text. db.Raw() is the exception: it requires explicit placeholders (?), and without them it is as vulnerable as string concatenation with database/sql. Prefer the query builder methods, which handle parameterization without the caller having to remember.

sqlx (Enhanced database/sql)

// SECURE - sqlx with named parameters
package main

import (
    "encoding/json"
    "log"
    "net/http"

    "github.com/jmoiron/sqlx"
    _ "github.com/lib/pq"
)

type User struct {
    ID    int    `db:"id"`
    Name  string `db:"name"`
    Email string `db:"email"`
}

var sqlxDB *sqlx.DB

func getUsersSqlx(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    minAge := r.URL.Query().Get("min_age")

    // SECURE - Named parameters with sqlx
    query := `
        SELECT id, name, email
        FROM users
        WHERE name LIKE :name
          AND age >= :min_age
    `

    params := map[string]interface{}{
        "name":    "%" + name + "%",
        "min_age": minAge,
    }

    rows, err := sqlxDB.NamedQuery(query, params)
    if err != nil {
        log.Printf("Query error: %v", err)
        http.Error(w, "Query failed", http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        var u User
        if err := rows.StructScan(&u); err != nil {
            log.Printf("Scan error: %v", err)
            continue
        }
        users = append(users, u)
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(users)
}

// SECURE - sqlx NamedQuery() with struct parameters
func getUserByEmailSqlx(w http.ResponseWriter, r *http.Request) {
    type SearchParams struct {
        Email string `db:"email"`
    }

    params := SearchParams{
        Email: r.URL.Query().Get("email"),
    }

    var user User
    // SECURE - NamedQuery with struct
    query := "SELECT id, name, email FROM users WHERE email = :email"
    rows, err := sqlxDB.NamedQuery(query, params)
    if err != nil {
        log.Printf("Query error: %v", err)
        http.Error(w, "Query failed", http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    if !rows.Next() {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }

    if err := rows.StructScan(&user); err != nil {
        log.Printf("Scan error: %v", err)
        http.Error(w, "Query failed", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func main() {
    var err error
    sqlxDB, err = sqlx.Connect("postgres", "postgres://user:pass@localhost/dbname?sslmode=require")
    if err != nil {
        log.Fatal(err)
    }
    defer sqlxDB.Close()

    http.HandleFunc("/users", getUsersSqlx)
    http.HandleFunc("/user/email", getUserByEmailSqlx)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Why this works: sqlx extends database/sql with named parameters (:name) and struct mapping, making parameterized queries more readable and maintainable. NamedQuery() converts named placeholders to positional placeholders and binds map/struct values as parameters, maintaining the same SQL injection protection as standard database/sql. The StructScan() method maps database columns to struct fields using tags, reducing boilerplate while preserving security. Naming the parameters also makes them harder to bind in the wrong order than positional placeholders are.

Chi Router with Middleware

// SECURE - Chi router with SQL injection protection
package main

import (
    "database/sql"
    "encoding/json"
    "log"
    "net/http"
    "strconv"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    _ "github.com/lib/pq"
)

var db *sql.DB

// User represents a database user
type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

// getUserHandler retrieves a user by ID from URL parameter
func getUserHandler(w http.ResponseWriter, r *http.Request) {
    userIDStr := chi.URLParam(r, "userID")

    // Convert and validate
    userID, err := strconv.Atoi(userIDStr)
    if err != nil {
        http.Error(w, "Invalid user ID", http.StatusBadRequest)
        return
    }

    // SECURE - Parameterized query
    var user User
    query := "SELECT id, name, email FROM users WHERE id = $1"
    err = db.QueryRow(query, userID).Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        if err == sql.ErrNoRows {
            http.Error(w, "User not found", http.StatusNotFound)
            return
        }
        log.Printf("Database error: %v", err)
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func main() {
    var err error
    db, err = sql.Open("postgres", "postgres://user:pass@localhost/dbname?sslmode=require")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    r := chi.NewRouter()

    // Middleware
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.RealIP)

    // Routes with parameterized queries
    r.Get("/users/{userID}", getUserHandler)

    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", r))
}

Why this works: Chi's URL parameter extraction (chi.URLParam) combined with type validation (strconv.Atoi) and parameterized queries provides defense-in-depth. Type conversion rejects non-numeric input early, while parameterization means a numeric-looking injection attempt such as 1 OR 1=1 is still bound as a literal. Chi's explicit routing keeps each query in a named handler reached from one r.Get line, so a reviewer can find every query construction point from the route table.

Input Validation (Defense in Depth)

// SECURE - Input validation alongside parameterization
import (
    "database/sql"
    "net/http"
    "regexp"
    "strconv"
)

var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)

func validateAndQueryUser(w http.ResponseWriter, r *http.Request) {
    email := r.URL.Query().Get("email")
    ageStr := r.URL.Query().Get("age")

    // Validation layer (defense in depth)
    if !emailRegex.MatchString(email) {
        http.Error(w, "Invalid email format", http.StatusBadRequest)
        return
    }

    age, err := strconv.Atoi(ageStr)
    if err != nil || age < 0 || age > 150 {
        http.Error(w, "Invalid age", http.StatusBadRequest)
        return
    }

    // Still use parameterization (primary defense)
    query := "SELECT id, name, email FROM users WHERE email = $1 AND age = $2"

    var user User
    err = db.QueryRow(query, email, age).Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        if err == sql.ErrNoRows {
            http.Error(w, "User not found", http.StatusNotFound)
            return
        }
        http.Error(w, "Query failed", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

Common Pitfalls

  • Mixing the two dynamic-WHERE-clause patterns: most conditions append a placeholder to the query and the value to args (as shown above), but a condition added later falls back to fmt.Sprintf("name = '%s'", value) directly into the query string. The surrounding code looks identical to the safe pattern, which makes the one concatenated condition easy to miss in review.
  • Treating GORM as safe everywhere because the query builder methods (Where(), Find()) auto-parameterize, then using db.Raw(query) with a concatenated string elsewhere in the same file. db.Raw() requires explicit ? placeholders; without them it is exactly as vulnerable as database/sql with string concatenation.
  • Binding sqlx named parameters correctly for values while building the underlying table name from a struct tag, config value, or request parameter with fmt.Sprintf and no allowlist check. Named-parameter binding only protects the positions it is used for, not identifiers assembled into the query text.
  • Validating a sort column against an allowlist map but skipping the same check for the sort direction, or applying the allowlist on only one of several handlers that build the same ORDER BY clause.

Additional Resources