flawopen.com/sql-injection/Swift
SQL Injection in Swift (SQLite3, GRDB)
Swift string interpolation such as "... = \(id)" inside sqlite3_prepare_v2(), GRDB fetch or execute calls lets untrusted input rewrite your SQL and return every row (CWE-89). The bound form for the SQLite C API and GRDB, why the same \() syntax is safe in a GRDB SQL literal, and how to sort by a chosen column.
Think of a tailor’s paper pattern: measurements belong in marked spaces, but a customer cannot add a new cutting instruction by writing into one. Swift string interpolation creates a finished SQL sentence before SQLite parses it, so text can become SQL instructions. sqlite3_prepare_v2() followed by sqlite3_bind_*() keeps the statement and values apart; GRDB fetchAll(sql:arguments:) and execute(sql:arguments:) do too. GRDB’s typed SQL literal is a special pattern that binds interpolated values, unlike an ordinary String. Since column names cannot be bound, choose ORDER BY from a switch of constant statements.
Core Concepts & Subsystem Terms
\(value) interpolation in SQL"... WHERE id = \(userId)"builds an ordinaryStringbefore any database API sees it. Swift's type system cannot tell SQL text from data once they are oneString, so the compiler gives no warning.sqlite3_prepare_v2 + sqlite3_bind_*sqlite3_prepare_v2compiles only the first statement and leaves?1slots;sqlite3_bind_int64orsqlite3_bind_textfills them. When binding a SwiftStringwithsqlite3_bind_text, passSQLITE_TRANSIENT(defined asunsafeBitCast(-1, to: sqlite3_destructor_type.self)) so SQLite copies the bytes.GRDB arguments:- GRDB's
fetchAll(db, sql:, arguments:)andexecute(sql:, arguments:)bindStatementArgumentsto?or:namemarkers.execute(sql:)also runs every statement in the string, so an interpolated value can append a second statement. SQLite.swift'sprepare(sql, bindings...)works the same way. GRDB SQL interpolation- GRDB can build requests from string literals typed as
SQLRequestorSQL:let request: SQLRequest<Row> = "... WHERE status = \(status)"turns each interpolated value into a bound argument. The same\(status)in a plainStringpassed tosql:is pasted in. The syntax is identical; the declared type decides the safety. switch over sort keys- Identifiers cannot be bound. A
switchthat returns one of a few complete, constant statements guarantees that only strings written in the source code reach SQLite.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-89
- OWASP Top 10:2025 — A05:2025 Injection
Step-by-Step Attack Flow
Untrusted input reaches the data layer
A universal link, a push-notification payload or a JSON field from your API supplies userId, which is passed to findEmail() as a String.
Interpolation builds the SQL
"... WHERE id = \(userId)" evaluates first. With the benign test value 0 OR 1=1, the statement reads WHERE id = 0 OR 1=1.
SQLite compiles the attacker's text
sqlite3_prepare_v2 receives a finished statement with no slots to bind, so SQLite parses OR 1=1 as part of the WHERE clause.
Every row, or an extra statement, runs
The lookup returns rows it should not. Through GRDB's execute(sql:) it is worse: a ; in the value starts a second statement, which also runs.
Source Code: Flaw vs. Secure Implementation
// UserStore.swift: each query below interpolates input into the SQL text
import GRDB
import SQLite3
// SQLite C API: \() builds the finished statement before prepare
func findEmail(db: OpaquePointer?, userId: String) -> String? {
var stmt: OpaquePointer?
let sql = "SELECT email FROM users WHERE id = \(userId)"
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
defer { sqlite3_finalize(stmt) }
guard sqlite3_step(stmt) == SQLITE_ROW, let text = sqlite3_column_text(stmt, 0) else { return nil }
return String(cString: text)
}
// GRDB: a plain String passed to sql: is sent as written
func findOrders(_ db: Database, status: String) throws -> [Row] {
try Row.fetchAll(db, sql: "SELECT id FROM orders WHERE status = '\(status)'")
}
// execute(sql:) runs every statement in the string
func revokeSessions(_ db: Database, userId: String) throws {
try db.execute(sql: "UPDATE sessions SET revoked = 1 WHERE user_id = '\(userId)'")
}
// ORDER BY column taken straight from the caller
func listUsers(_ db: Database, sortColumn: String) throws -> [Row] {
try Row.fetchAll(db, sql: "SELECT id, email FROM users ORDER BY \(sortColumn)")
}
// UserStore.swift: values are bound, statements are constants
import GRDB
import SQLite3
// SQLite C API: ?1 compiled first, value bound as a 64-bit integer
func findEmail(db: OpaquePointer?, userId: Int64) -> String? {
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, "SELECT email FROM users WHERE id = ?1", -1, &stmt, nil) == SQLITE_OK else { return nil }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_int64(stmt, 1, userId)
guard sqlite3_step(stmt) == SQLITE_ROW, let text = sqlite3_column_text(stmt, 0) else { return nil }
return String(cString: text)
}
// GRDB: ? marker, value passed in arguments
func findOrders(_ db: Database, status: String) throws -> [Row] {
try Row.fetchAll(db, sql: "SELECT id FROM orders WHERE status = ?", arguments: [status])
}
// execute with arguments binds the value to ?
func revokeSessions(_ db: Database, userId: String) throws {
try db.execute(sql: "UPDATE sessions SET revoked = 1 WHERE user_id = ?", arguments: [userId])
}
// switch picks one of three constant statements
func listUsers(_ db: Database, sortKey: String) throws -> [Row] {
let sql: String
switch sortKey {
case "email": sql = "SELECT id, email FROM users ORDER BY email"
case "created": sql = "SELECT id, email FROM users ORDER BY created_at"
default: sql = "SELECT id, email FROM users ORDER BY id"
}
return try Row.fetchAll(db, sql: sql)
}
Engineering & System Hardening Checklist
- ✓Add a SwiftLint custom rule or Semgrep rule that fails CI on
\(inside string arguments tosqlite3_prepare_v2,sqlite3_exec,fetchAll(_:sql:),fetchOne(_:sql:)andexecute(sql:). - ✓Pass every value through
sqlite3_bind_*, GRDBarguments:, or an interpolated literal explicitly typed asSQLRequestorSQL. - ✓Decode IDs into
Int64orUUIDat the boundary (Codable, URL parsing) so malformed input fails before any SQL is built. - ✓Choose ORDER BY with a
switchover complete constant statements or GRDBColumnvalues; never interpolate a requested column. - ✓Open read-only databases with
SQLITE_OPEN_READONLY(or GRDBConfiguration.readonly = true) and enable Data Protection on the database file.
Sources
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command
- SQLite — Binding Values To Prepared Statements
- GRDB.swift — SQL Interpolation
- GRDB.swift — Executing updates and fetching with arguments
- SQLite — One-Step Query Execution Interface (sqlite3_exec)
- OWASP SQL Injection Prevention Cheat Sheet