The title belongs to the caller. The query belongs to you.
You probably already pass search text from an input field to a server. That text can also
arrive from a script, a saved filter, or a request made without your UI. A TypeScript string tells you its runtime shape; it tells you nothing about who chose its contents.
Our server has already authenticated a Juniper agent and resolved their tenant ID to juniper. The agent may choose any ticket title. The lookup must return only exact title matches
in Juniper’s tickets.
- Asset
- Each company’s ticket titles and records.
- Caller controls
- The search title and requested sort choice.
- Trusted context
- The tenant ID resolved by the server from an authenticated session.
- Invariant
- Every returned row belongs to that tenant and has exactly the requested title.
The caller chooses the bytes.
Parsing the request keeps it untrusted.
Does it become SQL text or a bound value?
The database parses and executes the statement.
A sink is the operation that gives input a consequential meaning: executing a query, rendering HTML, starting a shell command. Here the dangerous boundary is the join between a caller’s title and the SQL text. SQL injection happens when that input can change the instructions the database executes.
A successful search can still be an unsafe search.
The first implementation works for Printer. It places the tenant and title
inside quoted SQL strings. The problem appears when the title itself contains SQL
punctuation. These examples intentionally contain the flaw and run only against a
disposable, in-memory SQLite fixture.
The same reading choice applies to the vulnerable code, repair, and tests.
// Intentionally vulnerable. Use only with the disposable lesson fixture.
export function vulnerableQuery(tenant: string, title: string): Query {
return {
text: `SELECT id, tenant_id, title FROM tickets WHERE tenant_id = '${tenant}' AND title = '${title}' ORDER BY id`,
values: []
};
}
// Intentionally vulnerable: preparing already-concatenated SQL does not repair it.
export function findUnsafe(db: DatabaseSync, tenant: string, title: string): Ticket[] {
const query = vulnerableQuery(tenant, title);
return db.prepare(query.text).all() as Ticket[];
} // Intentionally vulnerable. Use only with the disposable lesson fixture.
func FindUnsafe(ctx context.Context, db *sql.DB, tenant, title string) ([]Ticket, error) {
query := fmt.Sprintf("SELECT id, tenant_id, title FROM tickets WHERE tenant_id = '%s' AND title = '%s' ORDER BY id", tenant, title)
return read(ctx, db, query)
} Which part of the query can the caller change?
This runs the example’s query builders in your browser. It displays SQL and arguments; it does not execute SQL.
SELECT id, tenant_id, title FROM tickets WHERE tenant_id = 'juniper' AND title = '' OR 1=1 -- ' ORDER BY id
Arguments: none
SELECT id, tenant_id, title FROM tickets WHERE tenant_id = ? AND title = ? ORDER BY id
Arguments, in order:
[ "juniper", "' OR 1=1 -- " ]
In the concatenated text, OR 1=1 broadens the predicate and -- comments out the rest. In the bound version, every character belongs to one title value.
Compare your diagnosis with the fixtureReveal after making a prediction
- Observation
- A Kestrel row appeared in a Juniper search in the disposable database.
- Hypotheses
- The title changed SQL logic; the server chose the wrong tenant; or the fixture’s row ownership is wrong.
- Discriminating checks
- Hold the server tenant fixed, inspect the final SQL and arguments in the fixture test, verify the returned row’s stored tenant, then compare an ordinary title with the reproducing input.
- Conclusion here
- The tenant is still Juniper, the fixture row belongs to Kestrel, and the caller’s title altered the predicate. That supports SQL injection in this query path.
Try the title ' OR 1=1 --. Its first quote ends the title literal. OR 1=1 makes the filter true for every row, and -- comments out the trailing quote
and ordering. Because AND binds more tightly than OR, the true
branch bypasses the tenant condition too.
This is a read-only leak: no dropped table or second SQL statement is needed. In our
five-row fixture, Kestrel’s Payroll ticket appears in Juniper’s result. A database
account limited to reading would still be able to leak rows it can read.
One leaked row proves a boundary failure, not every possible consequence.
We observed a Kestrel ticket in Juniper’s result while the
server-supplied tenant stayed juniper. That supports a query-logic failure. It does
not yet tell us everything an attacker could reach in a deployed system.
A useful diagnosis separates what happened, what could happen under other permissions, and what this evidence does not establish. Use the fixture to bound this query’s behavior; inspect the production database role and other reachable queries before making a broader impact claim.
| Capability in the affected path | Possible consequence | What to establish |
|---|---|---|
| Read access to rows outside the caller’s tenant | Confidential data can be disclosed through this endpoint. | Which tables and columns the query can reach, and what the endpoint returns. |
| A write-capable query and database grants | Data could be inserted, changed, or deleted. | Whether the vulnerable path can execute those operations and the account is allowed to do so. |
| Authorization or account data in the affected query path | A query could bypass or alter a protection check. | Which authorization decision depends on the query and whether the caller can change its logic. |
| Privileged database features or host access | Some configurations allow administrative, file, or operating-system effects. | The specific database, driver, enabled features, operating-system permissions, and reachable query. |
Keep the SQL text fixed while the values change.
A parameterized query supplies SQL structure and parameter values separately. The ? placeholders below occupy value positions. The database treats the injected title as one string
to compare, so it cannot turn its quotes, operators, or comment marker into query syntax. Our
fixture has no title equal to that entire string, so the result is empty.
export function parameterizedQuery(tenant: string, title: string): Query {
return {
text: 'SELECT id, tenant_id, title FROM tickets WHERE tenant_id = ? AND title = ? ORDER BY id',
values: [tenant, title]
};
}
export function findSafe(db: DatabaseSync, tenant: string, title: string): Ticket[] {
const query = parameterizedQuery(tenant, title);
return db.prepare(query.text).all(...query.values) as Ticket[];
} func FindSafe(ctx context.Context, db *sql.DB, tenant, title string) ([]Ticket, error) {
return read(ctx, db,
"SELECT id, tenant_id, title FROM tickets WHERE tenant_id = ? AND title = ? ORDER BY id",
tenant, title)
}
func read(ctx context.Context, db *sql.DB, query string, args ...any) ([]Ticket, error) {
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
tickets := []Ticket{}
for rows.Next() {
var t Ticket
if err := rows.Scan(&t.ID, &t.Tenant, &t.Title); err != nil {
return nil, err
}
tickets = append(tickets, t)
}
return tickets, rows.Err()
} The TypeScript path uses Node’s SQLite API: prepare(query.text) receives the
fixed statement and all(...query.values) binds its arguments. The Go path
passes the statement and arguments to QueryContext; it also closes the rows
and checks the iteration error. The security boundary is the same in both.
Calling a method named prepare is not enough. The vulnerable TypeScript version
already does that, after interpolation has changed the SQL. Inspect what reaches the method,
not just the method’s name.
Your database may spell a placeholder differentlySQLite, PostgreSQL, and driver APIs
This fixture uses SQLite’s ?. With node-postgres, the corresponding call is client.query('SELECT … WHERE tenant_id = $1 AND title = $2', [tenant, title]). Its query documentation describes sending the text and parameters separately. Go’s SQL injection guidance likewise warns
that placeholder syntax depends on the database and driver.
Do not quote the placeholder as '?', interpolate into it first, or assume
every tagged template binds safely. Check the actual API. An ORM’s raw-query escape
hatch needs the same review.
A sort choice needs a mapping, not another placeholder.
The product now lets an agent choose oldest or newest first. Values and SQL grammar are
different things: a bind parameter does not stand in for a column name or ASC/DESC. Adding the raw sort string after ORDER BY would reopen the
boundary you just closed.
Expose two product choices. Map each to a fragment owned by the application, reject anything else, and continue binding the tenant and title. This is the allowlist approach described by OWASP for query parts that cannot be parameters.
export function sortedQuery(tenant: string, title: string, sort: string): Query {
// Only these application-owned fragments may become SQL syntax.
let order: string;
switch (sort) {
case 'oldest':
order = 'id ASC';
break;
case 'newest':
order = 'id DESC';
break;
default:
throw new Error('Unsupported sort');
}
return {
text: `SELECT id, tenant_id, title FROM tickets WHERE tenant_id = ? AND title = ? ORDER BY ${order}`,
values: [tenant, title]
};
} func FindSorted(ctx context.Context, db *sql.DB, tenant, title, sort string) ([]Ticket, error) {
var order string
switch sort {
case "oldest":
order = "id ASC"
case "newest":
order = "id DESC"
default:
return nil, fmt.Errorf("unsupported sort")
}
return read(ctx, db,
"SELECT id, tenant_id, title FROM tickets WHERE tenant_id = ? AND title = ? ORDER BY "+order,
tenant, title)
} Other places the same boundary reappearsLists, patterns, and stored values
Lists: an IN clause needs a supported array parameter or a generated
list of placeholders, with each element bound separately. Joining user values with commas
makes query text again.
Search patterns: binding a LIKE value prevents injection,
but % and _ still mean wildcards to LIKE. Decide
whether the feature permits patterns or needs database-specific literal escaping. This
lesson uses exact equality.
Stored values: a title loaded from your database can still be caller-controlled. If an export job later concatenates it into a different query, it can cause a second-order injection. Bind it at that sink too.
Make the exploit fail and the ordinary name succeed.
A mock that checks “the query function was called” cannot show how a database interprets SQL. These tests create a fresh SQLite database in each language, run the actual lookup, and inspect returned IDs and row ownership. The same language-neutral cases define the expected behavior.
| Input or change | Expected result | What it checks |
|---|---|---|
Printer | IDs 1 and 2 | Ordinary matches are preserved. |
O'Brien | ID 4 | Apostrophes stay valid data. |
' OR 1=1 -- | No rows | The predicate cannot be widened. |
Payroll | No rows | Another tenant’s title is still hidden. |
newest sort | IDs 2, then 1 | Only the intended order changes. |
| SQL text as a sort choice | Rejected | Only known structure is accepted. |
it('the same input crosses the tenant boundary only in the vulnerable lookup', () => {
const db = fixture();
try {
const input = "' OR 1=1 -- ";
expect(findUnsafe(db, 'juniper', input).map((row) => row.id)).toEqual([1, 2, 3, 4, 5]);
expect(findSafe(db, 'juniper', input)).toEqual([]);
expect(findSafe(db, 'juniper', "O'Brien").map((row) => row.id)).toEqual([4]);
} finally {
db.close();
}
}); func TestRegression(t *testing.T) {
db, err := Fixture()
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx := context.Background()
input := "' OR 1=1 -- "
unsafe, err := FindUnsafe(ctx, db, "juniper", input)
if err != nil || !reflect.DeepEqual(ids(unsafe), []int{1, 2, 3, 4, 5}) {
t.Fatalf("reproduction: %v %v", unsafe, err)
}
safe, err := FindSafe(ctx, db, "juniper", input)
if err != nil || len(safe) != 0 {
t.Fatalf("injection: %v %v", safe, err)
}
ordinary, err := FindSafe(ctx, db, "juniper", "O'Brien")
if err != nil || !reflect.DeepEqual(ids(ordinary), []int{4}) {
t.Fatalf("apostrophe: %v %v", ordinary, err)
}
} Keeping the vulnerable lookup in this isolated fixture proves that the case detects the original defect. In an application test suite, keep the regression against the repaired path; restoring concatenation should make it fail. Also test an empty title and statement-shaped input, and confirm the fixture still answers an ordinary query afterward.
If the reproduction returns no unauthorized rows, inspect the SQL and fixture before declaring success. A syntax error, missing test row, or earlier validation rejection can hide the route to the sink. Then test through the real application driver and authorization boundary: SQLite results do not establish another driver’s configuration or the correctness of your session handling.
Run the checks and inspect the complete filesTypeScript + Go · disposable SQLite fixtures
# From the repository root; Node 22.13+ and installed site dependencies.
node node_modules/vitest/vitest.mjs run --project server src/lib/content/lessons/sql-injection-and-parameterized-queries/examples/tickets.spec.ts
# Go 1.24+, CGO enabled, and a C compiler for go-sqlite3.
cd src/lib/content/lessons/sql-injection-and-parameterized-queries/examples/go
go test ./... The TypeScript suite uses the site’s installed Vitest. The Go module pins go-sqlite3; its driver requires CGO and a C compiler. The files below include both fixtures, the
shared cases, and the tests. No live application database is used.
queries.ts
export type Query = { text: string; values: (string | number)[] };
// Intentionally vulnerable. Use only with the disposable lesson fixture.
export function vulnerableQuery(tenant: string, title: string): Query {
return {
text: `SELECT id, tenant_id, title FROM tickets WHERE tenant_id = '${tenant}' AND title = '${title}' ORDER BY id`,
values: []
};
}
export function parameterizedQuery(tenant: string, title: string): Query {
return {
text: 'SELECT id, tenant_id, title FROM tickets WHERE tenant_id = ? AND title = ? ORDER BY id',
values: [tenant, title]
};
}
export function sortedQuery(tenant: string, title: string, sort: string): Query {
// Only these application-owned fragments may become SQL syntax.
let order: string;
switch (sort) {
case 'oldest':
order = 'id ASC';
break;
case 'newest':
order = 'id DESC';
break;
default:
throw new Error('Unsupported sort');
}
return {
text: `SELECT id, tenant_id, title FROM tickets WHERE tenant_id = ? AND title = ? ORDER BY ${order}`,
values: [tenant, title]
};
}
tickets.ts
import { DatabaseSync } from 'node:sqlite';
import { parameterizedQuery, vulnerableQuery, sortedQuery, type Query } from './queries';
export type Ticket = { id: number; tenant_id: string; title: string };
export function fixture(): DatabaseSync {
const db = new DatabaseSync(':memory:');
db.exec(`CREATE TABLE tickets (id INTEGER PRIMARY KEY, tenant_id TEXT NOT NULL, title TEXT NOT NULL);
INSERT INTO tickets VALUES (1, 'juniper', 'Printer'), (2, 'juniper', 'Printer'),
(3, 'kestrel', 'Payroll'), (4, 'juniper', 'O''Brien'), (5, 'juniper', '100%_done');`);
return db;
}
export function execute(db: DatabaseSync, query: Query): Ticket[] {
return db.prepare(query.text).all(...query.values) as Ticket[];
}
// Intentionally vulnerable: preparing already-concatenated SQL does not repair it.
export function findUnsafe(db: DatabaseSync, tenant: string, title: string): Ticket[] {
const query = vulnerableQuery(tenant, title);
return db.prepare(query.text).all() as Ticket[];
}
export function findSafe(db: DatabaseSync, tenant: string, title: string): Ticket[] {
const query = parameterizedQuery(tenant, title);
return db.prepare(query.text).all(...query.values) as Ticket[];
}
export function findSorted(
db: DatabaseSync,
tenant: string,
title: string,
sort: string
): Ticket[] {
return execute(db, sortedQuery(tenant, title, sort));
}
tickets.spec.ts
import { describe, expect, it } from 'vitest';
import cases from './cases.json';
import { fixture, findSafe, findUnsafe, findSorted } from './tickets';
describe('ticket lookup on real, disposable SQLite databases', () => {
for (const c of cases) {
it(c.name, () => {
const db = fixture();
try {
const rows = findSafe(db, c.tenant, c.title);
expect(rows.map((row) => row.id)).toEqual(c.ids);
expect(rows.every((row) => row.tenant_id === c.tenant && row.title === c.title)).toBe(true);
expect(findSafe(db, 'juniper', 'Printer').map((row) => row.id)).toEqual([1, 2]);
} finally {
db.close();
}
});
}
it('the same input crosses the tenant boundary only in the vulnerable lookup', () => {
const db = fixture();
try {
const input = "' OR 1=1 -- ";
expect(findUnsafe(db, 'juniper', input).map((row) => row.id)).toEqual([1, 2, 3, 4, 5]);
expect(findSafe(db, 'juniper', input)).toEqual([]);
expect(findSafe(db, 'juniper', "O'Brien").map((row) => row.id)).toEqual([4]);
} finally {
db.close();
}
});
it('accepts only named sort choices and preserves the tenant boundary', () => {
const db = fixture();
try {
expect(findSorted(db, 'juniper', 'Printer', 'newest').map((row) => row.id)).toEqual([2, 1]);
expect(findSorted(db, 'juniper', 'Printer', 'oldest').map((row) => row.id)).toEqual([1, 2]);
expect(() => findSorted(db, 'juniper', 'Printer', 'id; DROP TABLE tickets')).toThrow(
'Unsupported sort'
);
} finally {
db.close();
}
});
});
cases.json
[
{ "name": "ordinary title", "tenant": "juniper", "title": "Printer", "ids": [1, 2] },
{ "name": "another tenant's title", "tenant": "juniper", "title": "Payroll", "ids": [] },
{ "name": "legitimate apostrophe", "tenant": "juniper", "title": "O'Brien", "ids": [4] },
{ "name": "predicate injection", "tenant": "juniper", "title": "' OR 1=1 -- ", "ids": [] },
{
"name": "statement-shaped input",
"tenant": "juniper",
"title": "'; DROP TABLE tickets; --",
"ids": []
},
{ "name": "literal wildcard characters", "tenant": "juniper", "title": "100%_done", "ids": [5] },
{ "name": "empty title", "tenant": "juniper", "title": "", "ids": [] },
{ "name": "other tenant's own search", "tenant": "kestrel", "title": "Payroll", "ids": [3] }
]
go/tickets.go
package tickets
import (
"context"
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3"
)
type Ticket struct {
ID int
Tenant, Title string
}
func Fixture() (*sql.DB, error) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
_, err = db.Exec(`CREATE TABLE tickets (id INTEGER PRIMARY KEY, tenant_id TEXT NOT NULL, title TEXT NOT NULL);
INSERT INTO tickets VALUES (1, 'juniper', 'Printer'), (2, 'juniper', 'Printer'),
(3, 'kestrel', 'Payroll'), (4, 'juniper', 'O''Brien'), (5, 'juniper', '100%_done');`)
if err != nil {
db.Close()
return nil, err
}
return db, nil
}
// Intentionally vulnerable. Use only with the disposable lesson fixture.
func FindUnsafe(ctx context.Context, db *sql.DB, tenant, title string) ([]Ticket, error) {
query := fmt.Sprintf("SELECT id, tenant_id, title FROM tickets WHERE tenant_id = '%s' AND title = '%s' ORDER BY id", tenant, title)
return read(ctx, db, query)
}
func FindSafe(ctx context.Context, db *sql.DB, tenant, title string) ([]Ticket, error) {
return read(ctx, db,
"SELECT id, tenant_id, title FROM tickets WHERE tenant_id = ? AND title = ? ORDER BY id",
tenant, title)
}
func read(ctx context.Context, db *sql.DB, query string, args ...any) ([]Ticket, error) {
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
tickets := []Ticket{}
for rows.Next() {
var t Ticket
if err := rows.Scan(&t.ID, &t.Tenant, &t.Title); err != nil {
return nil, err
}
tickets = append(tickets, t)
}
return tickets, rows.Err()
}
func FindSorted(ctx context.Context, db *sql.DB, tenant, title, sort string) ([]Ticket, error) {
var order string
switch sort {
case "oldest":
order = "id ASC"
case "newest":
order = "id DESC"
default:
return nil, fmt.Errorf("unsupported sort")
}
return read(ctx, db,
"SELECT id, tenant_id, title FROM tickets WHERE tenant_id = ? AND title = ? ORDER BY "+order,
tenant, title)
}
go/tickets_test.go
package tickets
import (
"context"
"encoding/json"
"os"
"reflect"
"testing"
)
func ids(rows []Ticket) []int {
result := []int{}
for _, row := range rows {
result = append(result, row.ID)
}
return result
}
func TestSharedCases(t *testing.T) {
data, err := os.ReadFile("../cases.json")
if err != nil {
t.Fatal(err)
}
var cases []struct {
Name, Tenant, Title string
IDs []int
}
if err := json.Unmarshal(data, &cases); err != nil {
t.Fatal(err)
}
for _, c := range cases {
t.Run(c.Name, func(t *testing.T) {
db, err := Fixture()
if err != nil {
t.Fatal(err)
}
defer db.Close()
rows, err := FindSafe(context.Background(), db, c.Tenant, c.Title)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(ids(rows), c.IDs) {
t.Fatalf("got %v, want %v", ids(rows), c.IDs)
}
for _, row := range rows {
if row.Tenant != c.Tenant || row.Title != c.Title {
t.Fatalf("boundary crossed: %+v", row)
}
}
remaining, err := FindSafe(context.Background(), db, "juniper", "Printer")
if err != nil || !reflect.DeepEqual(ids(remaining), []int{1, 2}) {
t.Fatalf("fixture changed: %v %v", remaining, err)
}
})
}
}
func TestRegression(t *testing.T) {
db, err := Fixture()
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx := context.Background()
input := "' OR 1=1 -- "
unsafe, err := FindUnsafe(ctx, db, "juniper", input)
if err != nil || !reflect.DeepEqual(ids(unsafe), []int{1, 2, 3, 4, 5}) {
t.Fatalf("reproduction: %v %v", unsafe, err)
}
safe, err := FindSafe(ctx, db, "juniper", input)
if err != nil || len(safe) != 0 {
t.Fatalf("injection: %v %v", safe, err)
}
ordinary, err := FindSafe(ctx, db, "juniper", "O'Brien")
if err != nil || !reflect.DeepEqual(ids(ordinary), []int{4}) {
t.Fatalf("apostrophe: %v %v", ordinary, err)
}
}
func TestSort(t *testing.T) {
db, err := Fixture()
if err != nil {
t.Fatal(err)
}
defer db.Close()
for sort, want := range map[string][]int{"oldest": {1, 2}, "newest": {2, 1}} {
rows, err := FindSorted(context.Background(), db, "juniper", "Printer", sort)
if err != nil || !reflect.DeepEqual(ids(rows), want) {
t.Fatalf("sort %s: %v %v", sort, rows, err)
}
}
if _, err := FindSorted(context.Background(), db, "juniper", "Printer", "id; DROP TABLE tickets"); err == nil {
t.Fatal("accepted SQL as a sort choice")
}
}
go/go.mod
module heyrian.dev/lessons/sql-injection
go 1.24
require github.com/mattn/go-sqlite3 v1.14.33
Review the boundary, not a list of suspicious characters.
The ticket lookup leaks rows. Which repair keeps its contract?
Juniper must still be able to search for O’Brien. Authentication is already in place, and the title flows directly into the SQL shown above.
On a real change, trace every query fragment to its owner. Values should reach a binding API. Dynamic identifiers and keywords should come from a closed mapping. The identity used to scope results should come from the server’s authorization decision. Those are three different checks.
Input length limits, least-privilege database roles, generic client errors, and careful logging still matter. They bound work or consequences around the query. They do not repair string interpolation. Avoid logging raw search values just to prove that a probe arrived; record the relevant outcome without collecting more private data.
The question to leave beside a query: which bytes can the caller choose, and can any of them become SQL structure?
Connections to follow nextRelated lessons
Validation at the edge establishes accepted input shapes and domain rules before work begins. Parameterization handles the later SQL boundary.
Multi-tenancy follows tenant isolation across query layers, row policies, and separate databases. A safe query is one part of that design.
Test doubles helps you choose what a fake can establish and when the real dependency needs to run.
Sources and scopePrimary documentation · checked September 30, 2026
- OWASP: SQL Injection Prevention — parameterization, allowlisted structure, and additional defenses.
- node-postgres: Queries and Go: Avoiding SQL injection risk — binding APIs and driver-specific placeholders.
- MITRE CWE-89: SQL Injection — common consequences, including data disclosure and modification, vary with the vulnerable path and its privileges.
- OWASP: Injection Prevention — broader effects such as file or operating-system access depend on the database and its configuration.
- The example tests establish behavior on SQLite. They do not audit an application’s authentication, database permissions, other queries, or deployment.
Keep values and query structure in separate channels.
Build a tenant-scoped title search, map sort choices through a fixed allowlist, and create placeholders for a variable-length status filter. Each ticket runs in TypeScript and Go.
SQL injection practice 8 min
Keep search text out of SQL structure
This is an experiment with ticket-style exercises, giving beginners a feel for how tasks may be described in the workplace. Leave feedback
Checking your sign-in status. Your lesson remains available while we check.