Engineering

Injection-proof SQL builders in Go

Eric Chiang headshot

Eric Chiang

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?