Skip to content
gohacks.dev
Databases

SQL Injection in Go and How Query Parameters Prevent It

See SQL injection in Go with real output: a tautology, a UNION that leaks a column and a stacked DROP all work on concatenated SQL and fail with placeholders.

8 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

SQL injection is the oldest bug on the web's list, and it keeps appearing because it is so easy to write. A developer builds a query by joining strings, one of the strings comes from a user, and the user supplies SQL. The consequences range from reading data they should not see to destroying the database. In Go the fix is built in, and this article shows the attack and the fix with real output, on Go 1.25.6, using an in-memory SQLite database. Everything run here is a test against a throwaway database; the same ideas apply to any driver.

Tested with Go 1.25.6, modernc.org/sqlite v1.50.0, macOS on Apple Silicon (arm64)

The vulnerable function

main.goGo
package main

import (
	"database/sql"
	"fmt"
	"log"
	"strings"

	_ "modernc.org/sqlite"
)

func vulnerable(db *sql.DB, name string) []string {
	q := "SELECT name FROM users WHERE name = '" + name + "'" // string concatenation
	return names(db, q)
}

func safe(db *sql.DB, name string) []string {
	return names(db, "SELECT name FROM users WHERE name = ?", name)
}

func names(db *sql.DB, q string, args ...any) []string {
	rows, err := db.Query(q, args...)
	if err != nil {
		return []string{"ERROR: " + err.Error()}
	}
	defer rows.Close()
	var out []string
	for rows.Next() {
		var s string
		_ = rows.Scan(&s)
		out = append(out, s)
	}
	return out
}

// sortColumn cannot be a parameter: placeholders stand for values, never identifiers.
func sorted(db *sql.DB, column string) ([]string, error) {
	allowed := map[string]string{"name": "name", "created": "created_at"}
	col, ok := allowed[column]
	if !ok {
		return nil, fmt.Errorf("sort column %q is not allowed", column)
	}
	return names(db, "SELECT name FROM users ORDER BY "+col), nil
}

func main() {
	db, err := sql.Open("sqlite", "file:lab589?mode=memory&cache=shared")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
	_, _ = db.Exec(`create table users (name text, secret text, created_at int); insert into users values ('ada', 's3cret-ada', 1), ('grace', 's3cret-grace', 2)`)

	fmt.Println("normal input:       ", vulnerable(db, "ada"), safe(db, "ada"))

	payload := "x' OR '1'='1"
	fmt.Println("tautology:          ", vulnerable(db, payload), safe(db, payload))

	union := "x' UNION SELECT secret FROM users --"
	fmt.Println("union attack:       ", vulnerable(db, union), safe(db, union))

	_, err = sorted(db, "name; DROP TABLE users")
	fmt.Println("identifier attack:  ", err)
	got, _ := sorted(db, "created")
	fmt.Println("allowed identifier: ", got)

	stacked := "x'; DROP TABLE users; --"
	fmt.Println("stacked statement:  ", vulnerable(db, stacked))
	var n int
	err = db.QueryRow(`select count(*) from users`).Scan(&n)
	fmt.Printf("users after stacked attempt: n=%d err=%v\n", n, err)

	_ = strings.ToLower
}
Shell
go run . 2>&1
Text
normal input:        [ada] [ada]
tautology:           [ada grace] []
union attack:        [s3cret-ada s3cret-grace] []
identifier attack:   sort column "name; DROP TABLE users" is not allowed
allowed identifier:  [ada grace]
stacked statement:   []
users after stacked attempt: n=0 err=SQL logic error: no such table: users (1)

vulnerable builds its query like this:

Go
q := "SELECT name FROM users WHERE name = '" + name + "'"

safe uses a placeholder and passes the value separately:

Go
db.Query("SELECT name FROM users WHERE name = ?", name)

The table users has two rows, ada and grace, each with a secret. The program calls both functions with a series of inputs.

Normal input

With ada both return [ada]. Nothing distinguishes the two for honest data, which is why the bug survives code review and testing: it only shows when someone supplies something hostile.

The tautology

Input: x' OR '1'='1. After concatenation the SQL reads:

SQL
SELECT name FROM users WHERE name = 'x' OR '1'='1'

The condition is always true, and the result is [ada grace]: every row. In a login query (where name = '...' and password = '...') this is the classic bypass. The parameterised version returned [], because it searched for a user whose name is literally the string x' OR '1'='1, and there is none.

The UNION

Input: x' UNION SELECT secret FROM users --. The text becomes:

SQL
SELECT name FROM users WHERE name = 'x' UNION SELECT secret FROM users --'

The -- starts a comment that swallows the trailing quote. The query now returns the contents of the secret column, and the output is [s3cret-ada s3cret-grace]. The application was meant to return names, and the attacker read an unrelated column from the same table. With knowledge of the schema, or a query against information_schema or sqlite_master, the attacker can read any table the connection can reach. The parameterised version returned [] again.

The stacked statement

Input: x'; DROP TABLE users; --. The concatenated query contains two statements. In the lab's driver both ran: the vulnerable function returned [], and the count that followed failed with SQL logic error: no such table: users. The table was gone.

Whether stacked statements work depends on the driver and the protocol. This SQLite driver executes every statement in the string. PostgreSQL's extended query protocol, which pgx uses with parameters, rejects multiple statements in one prepared statement, and the MySQL driver refuses them unless multiStatements=true is in the DSN. Those defaults limit the damage of an injection, and they are not a defence: the first two attacks work on every database, and an attacker with a read primitive does not need a write.

Why placeholders work

With a placeholder, the SQL text sent to the database is fixed: SELECT name FROM users WHERE name = ?. The value travels separately. The database parses the text once, with the question mark as a slot for a value, and binds the value into the slot. The value is data at every point, and no sequence of characters in it can become syntax. That is a structural guarantee. It does not depend on escaping rules, character sets or which quotes the attacker uses.

Contrast with escaping functions. Escaping tries to make a string safe to place inside SQL text, and it has failed repeatedly: character set tricks, a forgotten case, a second layer that unescapes. Use parameters and the question does not come up.

The placeholders differ by driver: ? for MySQL and SQLite, $1, $2 for PostgreSQL. Whichever you use, never build the string by concatenation or fmt.Sprintf with user data. A linter such as gosec flags fmt.Sprintf and string concatenation in queries (rule G201 and G202); it is described here and was not run.

What placeholders cannot carry

A placeholder stands for a value: a string, a number, a date. It cannot stand for a table name, a column name, a sort direction, a keyword or a list of unknown length. When these come from the user, you still need a defence, and it is an allowlist.

The sorted function maps the requested column through a fixed map:

Go
allowed := map[string]string{"name": "name", "created": "created_at"}
col, ok := allowed[column]
if !ok {
	return nil, fmt.Errorf("sort column %q is not allowed", column)
}

The attacker's name; DROP TABLE users is rejected with sort column "name; DROP TABLE users" is not allowed, and the allowed created column works and returns the names in order. The value that reaches the SQL text is always one of your own constants, never the user's string. The same applies to sort direction (asc or desc only), table names chosen at run time, and column lists. For an IN list of unknown length, generate the right number of placeholders and pass the values; the pattern is in dynamic query building.

LIKE patterns need one more thought: a placeholder keeps the value from becoming SQL, but the user can still include % and _, which are wildcards. If the search term should be literal, escape those characters in the value and add an ESCAPE clause.

Other places injection hides

  • ORM raw fragments. db.Where("name = '" + name + "'") in an ORM is the same bug with a different API. Use the placeholder form the ORM provides.
  • Stored procedures that build dynamic SQL inside the database with concatenation. The injection happens server side.
  • Second-order injection. A value is stored safely, then read back later and concatenated into another query. Treat data from your own database as untrusted when it goes into SQL text.
  • Logging and error messages that print queries can leak the structure an attacker needs. Return generic errors to clients.
  • Numeric parameters parsed from strings: convert with strconv.Atoi first and fail on bad input.

Test it like an attacker would

The inputs in the lab are a starter set of probes, and they fit in a table test. For each function that builds a query from input, run the table: a single quote, ' OR '1'='1, a comment marker --, a semicolon, a very long string, a string with a NUL byte and a UNION probe. The expected result for a parameterised query is always the same: no rows, no data from other columns and no error other than "not found". The test takes a few lines with the techniques in table-driven tests, and it runs in microseconds against an in-memory database. It will not catch a vulnerability introduced in a different function, which is why the review habit of grepping for string-built SQL matters as much as the test.

If you are auditing an existing code base, search for these shapes: Sprintf( with SELECT, INSERT, UPDATE or DELETE in the same expression; " + adjacent to a SQL keyword; fmt.Sprint building an IN (...) list; and ORM calls whose first argument is a built string. Each hit needs a decision: convert to a placeholder, or prove the interpolated part is a constant.

Defence in depth

Parameters are the fix. Add layers that limit what a missed one can do.

  1. Least privilege. The application's database role should not own tables, drop them or read other schemas. Use a separate role for migrations.
  2. Separate read-only roles for reporting paths.
  3. Input validation for shape (an ID is digits), not as a substitute for parameters.
  4. A web application firewall is a speed bump for scanners, not a defence for you.
  5. Review and automate: search for Sprintf and + near SQL keywords in code review, run gosec in CI, and test with hostile input like the lab's.

A cheap test is worth writing for every query that takes input: call it with x' OR '1'='1 and assert that it returns no rows and no error beyond not found. The OWASP SQL injection prevention cheat sheet lists the defences by priority, the Go team's guide on avoiding SQL injection risk gives the same advice for database/sql, and the next article covers building dynamic queries safely. For the wider hardening checklist for APIs, see the API security checklist.

Also inSecurity
Taggedhardening
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.