Injection-proof SQL builders in Go

CTO / Founder
A Go product that uses SQL will inevitably implement some higher level logic on
top of database/sql. There are just too many cases where a single
string with a fixed set of arguments isn’t flexible enough. Using different
database flavors for dev and prod which take different placeholders ("?"
vs
"$1"
). Inserting multiple rows in a single statement.
Performing the same query with different WHERE conditions.
While builders are often necessary for development, they’re also absolutely terrifying for security.
Sure, Go has built-in parameterized values for input variables, but what if we’re trying to specify column, row, or table names? Most packages will happily accept arbitrary input in these fields and run it directly against your database.
// Runnable example: https://go.dev/play/p/bGiCWp6xk-z
package main
import (
"fmt"
"github.com/huandu/go-sqlbuilder"
)
func main() {
userInput := `1;
DROP TABLE students;
SELECT (id, name) FROM demo.user WHERE status`
where := `status = ` + userInput
// ...
sql := sqlbuilder.Select("id", "name").From("demo.user").
Where(where).
String()
fmt.Println(sql)
}
When building Oblique, we weren’t thrilled with the idea of accidentally introducing a 90s vulnerability into a security product built in 2025. If a customer trusts Oblique to manage authorization in their environment, we should do more than hope our new backend hire doesn’t misuse a Go API.
A better way
There turns out to be a clever trick with the Go type system to ensure an argument is free from dynamic input. That way, we can constrain the builder’s inputs rather than sanitize or detect after the fact.
Consider the following package:
package say
import "fmt"
type myString string
func Hello(name myString) {
fmt.Printf("Hello, %s!\n", name)
}
Because myString
is private, there’s no way for an external package to create
a variable of that type.
This should make it impossible for another package to call say.Hello
, except
for one notable exception. Go is relatively strict on mixing types. You can’t
add an int
to a float64
or even an int
to an int64
. To compensate, Go
constants allow programs to define untyped values whose type is
inferred when they’re used in some context that requires one. That’s why you can
do something like the following:
const thirtyDays = 30*24*time.Hour
Rather than explicitly typing every number:
const thirtyDays = time.Duration(30)*time.Duration(24)*time.Hour
The constants 30
and 24
are coerced to a time.Duration
by being multiplied by time.Hour
.
Let’s go back to our earlier example. While another package can’t create a
variable with the myString
type, it is possible to pass a constant! This will
get typed as myString
simply by being used as an argument value.
// Runnable example: https://go.dev/play/p/_0NyeiTp-M8
func main() {
say.Hello("Eric") // Works even though the argument is a private type.
}
Constants and builders
We can use this observation to force callers to only pass constants to our APIs,
which by definition will never be dependent on dynamic input. You can’t
construct a constant using fmt.Sprintf
or other string concatenation that
depends on a live variable.
Here’s a full example builder that uses private string types for column, row, and table names:
package sqlb
import "strings"
// Private string types that can’t be referenced by other packages.
type col string
type row string
type table string
// Helpers to construct types dynamically, while still requiring
// constant strings as arguments.
func Row(val row) row { return val }
func Rows(vals ...row) []row { return vals }
func Table(val table) table { return val }
type SelectBuilder struct {
rows []row
from table
whereEq *whereEq
}
func Select(rows ...row) *SelectBuilder {
return &SelectBuilder{rows: rows}
}
func (b *SelectBuilder) From(table table) *SelectBuilder {
b.from = table
return b
}
type whereEq struct {
col col
val any
}
func (b *SelectBuilder) WhereEq(col col, val any) *SelectBuilder {
b.whereEq = &whereEq{col, val}
return b
}
func (s *SelectBuilder) String() string {
b := &strings.Builder{}
b.WriteString("SELECT (")
for i, row := range s.rows {
if i != 0 {
b.WriteString(", ")
}
b.WriteString(string(row))
}
b.WriteString(") FROM ")
b.WriteString(string(s.from))
if s.whereEq != nil {
b.WriteString(" WHERE ")
b.WriteString(string(s.whereEq.col))
b.WriteString(" = ?")
}
return b.String()
}
With this package, you can still write the basic builder logic you’d expect:
// Runnable example: https://go.dev/play/p/Nauyr8LNdUr
var rows = sqlb.Rows("id", "name")
func main() {
sql := sqlb.Select(rows...).
From("demo.user").
WhereEq("status", 1).
String()
fmt.Println(sql)
}
But, if you ever accidentally depend on a string variable, the program refuses to compile!
var rows = sqlb.Rows("id", "name")
func main() {
userInput := ` = 1; DROP TABLE students;`
whereCol := `status` + userInput
// ...
sql := sqlb.Select(rows...).
From("demo.user").
WhereEq(whereCol, 1).
String()
fmt.Println(sql)
}
// cannot use whereCol (variable of type string) as sqlb.col value in argument to sqlb.Select(rows...).From("demo.user").WhereEq
Helpers like Row(val row) row
still allow other packages to dynamically
construct the set of rows to select, but guarantee their values trace back to
constants and aren’t dependent on user controlled values.
Footguns
Smart engineers at prominent companies accidentally write vulnerabilities like this all the time. As a codebase gets bigger, it’s just not possible to depend on human review as the security check. Nor is this restricted to SQL. Similar issues with JWT libraries and archive unpacking are routine when APIs make it easy to accidentally do the wrong thing.
If you are building a package with security implications, it should be as hard as possible (if not impossible) for users to do something insecure. For SQL builders, what’s better than insecure code not compiling at all?