← Security
Technique Output handling and injection

SQL injection and
parameterized queries

Keep a title from rewriting a query.

A support agent searches for a ticket by title. Your code adds the agent’s company to the query, so the results should stay inside that company. Then one title changes what the database is being asked to do. Let’s follow that value, repair the boundary, and keep a test that catches the same mistake next time.

The skill to keep

Find where untrusted data becomes instructions. Give the data its own channel, then verify that both ordinary use and the security rule still hold.

TypeScriptGo One ticket search · a trust boundary · real database tests
01 / Trace the input

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.

Case file / Support searchA caller-controlled title reaches a shared tickets table.
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.
01 / SourceRequest title

The caller chooses the bytes.

02 / CarryHandler argument

Parsing the request keeps it untrusted.

03 / BoundaryQuery construction

Does it become SQL text or a bound value?

04 / SinkSQL interpreter

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.

Leave with: one input you do not trust, the sink it reaches, and a property that must hold for every result.
02 / Reproduce the leak

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.

Read the query in your languages.

The same reading choice applies to the vulnerable code, repair, and tests.

TypeScriptIntentionally vulnerable · fixture only
tickets.ts · SQLite
// 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[];
}
GoIntentionally vulnerable · fixture only
tickets.go · database/sql + SQLite
// 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)
}
Inspect the boundary

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.

Concatenated · input becomes syntax
SELECT id, tenant_id, title FROM tickets WHERE tenant_id = 'juniper' AND title = '' OR 1=1 -- ' ORDER BY id

Arguments: none

Parameterized · syntax stays fixed
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.

Before reading the explanation: after you run the fixture, if it returns another tenant’s ticket, write down that observation, two plausible causes, and one safe check that could distinguish them.
Compare your diagnosis with the fixtureReveal after making a prediction
Diagnostic checkpoint Separate the observation from the cause.
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.

Leave with: the exact query that widened the result, and the unauthorized row that proves the boundary failed.
03 / Diagnose the impact

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.

Impact depends on the query path, data, and database authority
Capability in the affected pathPossible consequenceWhat to establish
Read access to rows outside the caller’s tenantConfidential 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 grantsData 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 pathA 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 accessSome configurations allow administrative, file, or operating-system effects.The specific database, driver, enabled features, operating-system permissions, and reachable query.
Leave with: the demonstrated impact, the capabilities that could increase it, and the evidence still needed to assess a deployed system.
04 / Bind the values

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.

TypeScriptRepaired · values stay separate
tickets.ts · SQLite
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[];
}
GoRepaired · values stay separate
tickets.go · database/sql + SQLite
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.

Leave with: a statement whose structure is independent of the title, plus a separate argument list.
05 / Constrain the structure

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.

TypeScriptDynamic order · closed set of SQL fragments
tickets.ts · SQLite
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]
	};
}
GoDynamic order · closed set of SQL fragments
tickets.go · database/sql + SQLite
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.

Leave with: a small, reviewable set of allowed query shapes. User values never supply a new shape.
06 / Verify the boundary

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.

Expected behavior for Juniper’s searches
Input or changeExpected resultWhat it checks
PrinterIDs 1 and 2Ordinary matches are preserved.
O'BrienID 4Apostrophes stay valid data.
' OR 1=1 --No rowsThe predicate cannot be widened.
PayrollNo rowsAnother tenant’s title is still hidden.
newest sortIDs 2, then 1Only the intended order changes.
SQL text as a sort choiceRejectedOnly known structure is accepted.
TypeScriptRegression · actual database results
tickets.ts · SQLite
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();
	}
});
GoRegression · actual database results
tickets.go · database/sql + SQLite
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
Run the database checks
# 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
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
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
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
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
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
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
go/go.mod
module heyrian.dev/lessons/sql-injection

go 1.24

require github.com/mattn/go-sqlite3 v1.14.33
Leave with: a regression that fails for concatenation, passes for binding, and preserves legitimate input.
07 / Make the next call

Review the boundary, not a list of suspicious characters.

Try a decision

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.

Your next move

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
08 / Practice in code

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.

TypeScript Go

SQL injection practice 8 min

This is an experiment with ticket-style exercises, giving beginners a feel for how tasks may be described in the workplace. Leave feedback

Keep search text out of SQL structure

TYPESCRIPT

Work item SEC-118

Keep search text out of SQL structure

Implementation exercise Ready

Context

A support agent searches tickets inside a tenant. The title and tenant ID are values; neither may change the query structure.

Acceptance criteria
  1. AC-1Use one fixed query with tenant and exact title predicates.
  2. AC-2Pass tenant ID and title as separate values in placeholder order.
  3. AC-3Preserve ordinary apostrophes and SQL-shaped input as literal values.
Notes
  • The server already obtained tenantID from authenticated context.
  • This task uses SQLite question-mark placeholders.
  • Binding protects the query shape; it does not decide whether the caller may access the tenant.

Copy the ticket to research the problem in your own notes or AI tool. Your code stays here.

Your implementation

Edit the function in the editor. Run the visible checks as often as you like; your code stays in this tab.

Checks cover

  • Ordinary search keeps tenant scope
  • Apostrophes remain valid data
  • Injection-shaped title stays in one bound value