Skip to content

Security

LiteORM is built so the safe path is the default one: every value you filter, insert, or update is sent to the database as a bound parameter, never spliced into SQL text. This guide collects the security-relevant guarantees and controls in one place — parameterized SQL, encryption at rest, locking down the studio, auditing writes, and keeping secrets out of your logs.

The SQL generator never interpolates values into statement text. Typed predicates, builder clauses, and repository writes all emit ? placeholders; internal/sqlgen renumbers those placeholders to the dialect’s form ($1 on Postgres, ? on SQLite/MySQL, @p1 on SQL Server) in one pass at build time, and the values travel separately as bind arguments. A predicate like query.Col[string]("email").Eq(userInput) renders "email" = ? with userInput bound out of band — there is no code path that concatenates a value into the query string, so classic string-injection has nothing to attack.

// The value never touches the SQL text — it is a bind parameter.
users, err := query.Select[User](db).
Filter(query.Col[string]("email").Eq(untrustedInput)).
All(ctx)
// → SELECT ... FROM "users" WHERE "email" = ? args=[untrustedInput]

Identifiers are handled separately and safely: table and column names are quoted through the dialect’s QuoteIdent, and typed predicates validate the column against the model’s fields at build time — an unknown column is a build error, not a malformed query. You reference columns by the names your struct declares, not by interpolating strings.

Where("frag", args...), Having, join ON conditions, and query.Raw[T] let you drop to raw SQL fragments. These still pass args as bind parameters — the safe way to include a value is a ? marker plus the value in args, exactly as with a typed predicate:

// Safe: the value is a bound argument.
query.Select[Product](db).Where("price > ?", limit)
// Unsafe: never build a fragment by concatenating a value into the string.
query.Select[Product](db).Where(fmt.Sprintf("price > %d", limit)) // don't do this

The rule is simple: fragment text is a constant you write, values go in args. Reserve raw fragments for SQL the typed builder can’t express, and keep untrusted input out of the fragment string.

For SQLite, LiteORM opens a transparent, page-level encrypted database: pass a key at open time and every page on disk is ciphertext. The whole file is encrypted, the key is a secret you source from a key-management service, and a wrong key fails rather than returning garbage.

db, err := sqlite.OpenEncrypted("app.db", key) // key is a 32-byte []byte

See At-rest encryption for cipher choice (Adiantum / AES-XTS), key derivation, and constraints. That page also draws the line between whole-database encryption, the vault for encrypting selected values, and field codecs for encrypting a single column — pick the narrowest layer that meets your requirement.

The embedded studio is an admin surface with a raw-SQL escape hatch, and it ships no authentication of its own. It returns a plain http.Handler, so you mount it behind the auth middleware you already run and never expose it unauthenticated:

// Always wrap the handler with your own authentication.
http.Handle("/studio/", requireAdmin(http.StripPrefix("/studio", studio.Handler(db))))

Narrow what it can do with studio.WithReadOnly() (no writes) and studio.WithDisableSQL() (no ad-hoc SQL), or bake read-only in at compile time with -tags studio_readonly for a public demo that cannot be flipped back on. The AI features run through one server-side WithAI hook, so your model API key stays on the server and never reaches the browser.

Every statement LiteORM runs passes through the Observer seam, which makes it the natural place to build an audit log. BeforeQuery / AfterQuery receive a QueryEvent carrying the operation, SQL, bind arguments, duration, rows affected, and error — register an observer with WithObserver and record what you need:

type auditor struct{ sink AuditSink }
func (a auditor) BeforeQuery(ctx context.Context, ev *liteorm.QueryEvent) context.Context {
return ctx
}
func (a auditor) AfterQuery(ctx context.Context, ev *liteorm.QueryEvent) {
if ev.Op == liteorm.MsgExec { // an INSERT/UPDATE/DELETE/DDL
a.sink.Record(ctx, ev.SQL, ev.Rows, ev.Err)
}
}
db, _ := sqlite.Open("app.db", liteorm.WithObserver(auditor{sink: mySink}))

QueryEvent.Args always carries the real bind values (redaction applies to the statement log, not to observers), so an observer that forwards them to an audit sink must redact sensitive fields itself. See observability for the full observer contract.

For a row-level, replayable trail on SQLite specifically, changesets capture the exact before/after of every changed row over a unit of work — invertible for undo and concatenable for a durable audit or one-way replication stream, independent of your application logging.

Statement logging records bind argument values by default, which is what makes a query traceable — but in a sensitive environment that can leak secrets into your logs. Redact argument values (only their count is logged) with WithSQLArgs(false):

db, _ := sqlite.Open("app.db",
liteorm.WithLogger(logger),
liteorm.WithSQLArgs(false), // statements log arg counts, not values
)

Two more log-side protections apply automatically: large bind values are bounded (a string over 256 bytes is truncated to a preview, a []byte over 256 bytes becomes a <N bytes> summary), and streamed large-object content never appears at all — an orm.LOB field binds an id, not the payload. Remember that WithSQLArgs(false) affects the built-in log only; an observer forwarding QueryEvent.Args to another sink must do its own redaction.

  • At-rest encryption — whole-database encryption, cipher and key handling.
  • Statement logging — argument redaction and value bounding.
  • Studio — mounting the admin GUI behind auth, read-only and no-SQL modes.
  • Observability — the Observer seam behind audit and tracing.
  • Changesets — row-level capture for audit, replication, and undo.