Security Best Practices
Protect your application from common security vulnerabilities
1. SQL Injection Prevention
Stabilize ORM never interpolates a value into SQL. Every statement the library generates is written with ? placeholders, and every value is bound as a parameter. Before a statement reaches a driver, rewritePlaceholders() converts the placeholders into the dialect's own syntax — $1 for PostgreSQL, @param0 for SQL Server. MySQL and SQLite take ? as written.
Reads go through the query builder, whose where() parameters are bound. Raw statements accept a parameters array, which is bound the same way.
// Safe: Parameterized query (automatic)const user = await userRepo .find() .where("email = ?", userInput) .execute(orm.client);
// Safe: Raw query with parameters - the value is bound, not pasted inconst result = await orm.rawQuery( "SELECT * FROM users WHERE email = ?", [userInput]);
// Safe: Raw write - same parameter bindingawait orm.rawExec( "UPDATE users SET locked = ? WHERE email = ?", [true, userInput]);
// Dangerous: concatenating user input into the SQL string// The ORM cannot protect a value that never reaches it as a parameter.const result = await orm.rawQuery( "SELECT * FROM users WHERE email = '" + userInput + "'");Parameter binding, not escaping
Values are passed to the driver separately from the statement, so a string containing quotes or SQL keywords is data and nothing else. The gap is always the same one: building a query string yourself with + or a template literal. Pass the value in the params array instead.
2. Secure Database Credentials
Never hardcode credentials. Read them from the environment:
DATABASE_URL=postgresql://user:password@localhost:5432/myappREDIS_URL=redis://localhost:6379ORM_ENCRYPTION_KEY=....env.env.local.env.*.localconst dbConfig: DBConfig = { type: DBType.Postgres, connectionString: process.env.DATABASE_URL!, // From environment};3. Input Validation
Stabilize supports built-in column validation. You can also use external libraries:
// Built-in validationconst User = defineModel({ tableName: "users", columns: { email: { type: DataTypes.STRING, required: true, pattern: /^[^@]+@[^@]+\.[^@]+$/, customValidator: (val) => val.length <= 255 || "Email too long", }, name: { type: DataTypes.STRING, minLength: 2, maxLength: 100, }, },});
// With zod (external)import { z } from "zod";const userSchema = z.object({ email: z.string().email(), name: z.string().min(1).max(100),});const validated = userSchema.parse(input);await userRepo.create(validated);4. Row-Level Security
// Verify ownership before returning dataasync function getPost(postId: string, userId: string) { const post = await postRepo.findOneBy({ id: postId, authorId: userId }); if (!post) throw new Error("Not found or access denied"); return post;}
// Or use scopesconst Post = defineModel({ tableName: "posts", columns: { /* ... */ }, scopes: { ownedBy: (qb, userId: string) => qb.where("authorId = ?", userId), },});
const userPosts = await postRepo.scope("ownedBy", currentUserId).execute(orm.client);5. Protect Sensitive Data
A column marked encrypted: true is encrypted on the way in and decrypted on the way out, transparently, by the repository. Mark the column and supply a key — or let the ORM generate and store one for you — and nothing else in your code changes:
const User = defineModel({ tableName: "users", columns: { id: { type: DataTypes.STRING, required: true }, ssn: { type: DataTypes.STRING, encrypted: true }, // stored as ciphertext email: { type: DataTypes.STRING, required: true }, },});
// Reads return plaintext: findOne, findBy, first, paginate and pluck all// pass the row through the decrypting transform.const user = await userRepo.findOne(id);console.log(user.ssn); // the original value# 64 hex characters (32 bytes), or a 32-byte utf8 string.# Omit it entirely and the ORM generates one into .stabilize/encryption.key.ORM_ENCRYPTION_KEY=9f2c1d7a4b8e35061c9a7d2f4e8b1a3c5d7f9b2e4a6c8d0f1b3e5a7c9d1f3b5eEncryption is AES-256-GCM. Each value is written as v3:<keyId>:<iv>:<auth tag>:<ciphertext> with a fresh random IV, all Base64 — the authentication tag means a value that was truncated or tampered with fails to decrypt rather than silently returning corrupted plaintext. The keyId is a digest of the key, not a label someone assigned, so a value names which key wrote it and a key that moves between the environment and the key file keeps its identity. Older rows in the v2 and pre-v2 CBC formats are still readable, though nothing writes them any more.
A key is found, or generated — never silently absent
The key is looked for in ORM_ENCRYPTION_KEY, then in a key file (ORM_ENCRYPTION_KEY_FILE, default .stabilize/encryption.key). If neither is present one is generated and written to that file before it is used, with a warning — a key held only in memory would orphan every value it encrypted at the next restart. The generated file is only as durable as the filesystem it lands in, so treat it as a secret and keep it out of version control. Rotating later is supported: list the old key in ORM_ENCRYPTION_KEYS_OLD or the file's retired list and it still decrypts while the new one writes. The key this library used to hard-code (f71a3c8e9b12d5a49c0a3f98b1f2e46d) is therefore public knowledge; rows written by an older version were encrypted with it and offer no real confidentiality, so set that value to keep reading them, re-save the rows under a key of your own, then drop it. There is no automatic re-encryption tool.
Encryption covers storage. Password hashing is a separate concern and stays in your hands — use a slow, salted algorithm:
import { hash, verify } from "@node-rs/argon2";
const hashedPassword = await hash(password);await userRepo.create({ id: generateUUID(), email, password: hashedPassword });
// Verify on loginconst isValid = await verify(user.password, inputPassword);6. Use Transactions for Critical Operations
A group of writes that must all land, or none, belongs in one transaction. transaction() takes a single argument — the callback — and hands it a client that every repository method accepts as its last parameter:
await orm.transaction(async (txClient) => { const sender = await accountRepo.findOne(fromId, {}, txClient); if (!sender || sender.balance < amount) { throw new Error("Insufficient funds"); }
await accountRepo.update( fromId, { balance: sender.balance - amount }, txClient, ); const receiver = await accountRepo.findOne(toId, {}, txClient); await accountRepo.update( toId, { balance: receiver.balance + amount }, txClient, );
// Returning commits. A throw anywhere above rolls the whole thing back.});One argument, no savepoints
transaction() does not take a second parameter for an existing client, and there is no isolation-level option on the call. A nested transaction() detects that it is already inside one and reuses the outer transaction instead of opening a second one — so there are no savepoints, and no partial rollback. An inner failure takes down the entire transaction; you cannot catch it and keep the outer work. Keep the callback short, and never make a network call to a third party inside it.
7. Implement Rate Limiting
This is application-level advice, not an ORM feature — the database is not the right place to notice that one address has tried to log in ten thousand times. Put a limiter in front of the endpoints that are worth brute-forcing:
import { Ratelimit } from "@upstash/ratelimit";import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 attempts per minute});
async function login(email: string, password: string, ip: string) { const { success } = await ratelimit.limit(ip); if (!success) { throw new Error("Too many login attempts. Please try again later."); }
// Proceed with login}Rate-limit by the identifier the attacker has to change — the account being targeted, not only the IP — and apply the same ceiling to password resets, one-time codes and anything else that can be guessed.
8. Audit Logging
Stabilize has two mechanisms for a record of what happened, and they cover different things. For row-level changes, use lifecycle hooks on the model:
import { defineModel, registerHooks } from "stabilize-orm";
const User = defineModel({ tableName: "users", columns: { /* ... */ },});
registerHooks(User, { afterCreate: async (user) => { await auditRepo.create({ action: "USER_CREATED", userId: user.id, timestamp: new Date().toISOString(), }); }, afterUpdate: async (user) => { await auditRepo.create({ action: "USER_UPDATED", userId: user.id, timestamp: new Date().toISOString(), }); }, afterDelete: async (user) => { await auditRepo.create({ action: "USER_DELETED", userId: user.id, timestamp: new Date().toISOString(), }); },});For the ORM's own activity, pass a LoggerConfig as the third constructor argument. Query text, bound parameters and execution time are written to the file, with rotation:
import { Stabilize, LogLevel, type LoggerConfig } from "stabilize-orm";
const loggerConfig: LoggerConfig = { level: LogLevel.Debug, filePath: "logs/stabilize.log", maxFileSize: 5 * 1024 * 1024, // rotate at 5MB maxFiles: 3, // keep 3 files};
export const orm = new Stabilize(dbConfig, { enabled: false, ttl: 60 }, loggerConfig);All nine events fire, but one fires too early to hear
orm.events is a StabilizeEmitter with on(), off() and emit(). All nine declared names are emitted: query, error, connection:open, connection:close, the two migration:* names and the three transaction:* names. The one that will not reach a handler registered on orm.events is connection:open: it fires from the constructor, before your code runs, so pass your own emitter as the fifth constructor argument if you need it. Everything else can be subscribed to normally, and an audit trail built on them is sound. See Events.
An audit record is only worth keeping if it is complete and trustworthy: write it in the same transaction as the change it describes, keep it append-only, and never let it store the sensitive value it is recording (log the fact that a column changed, not its new contents).
9. Principle of Least Privilege
Infrastructure advice rather than an ORM feature, and the cheapest control here: the account your application connects with should be able to do its job and nothing more. Migrations are the usual exception — run them as a separate, more privileged user.
-- Read-only user for reportingCREATE USER reporting_user WITH PASSWORD 'secure_password';GRANT SELECT ON ALL TABLES IN SCHEMA public TO reporting_user;
-- Application user: row data onlyCREATE USER app_user WITH PASSWORD 'secure_password';GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
-- Do not grant DROP, TRUNCATE or ALTER to the application user, and do not-- let it connect as the database owner or a superuser.Security Checklist
- Use parameterized queries (automatic in Stabilize)
- Store credentials in environment variables
- Validate input with column constraints and external schemas
- Implement row-level security checks
- Use
encrypted: truefor sensitive fields, and supply the key yourself —ORM_ENCRYPTION_KEYor a key file — rather than letting one be generated, and never rely on the published legacy key - Hash passwords with a slow, salted algorithm
- Enable optimistic locking for concurrent writes
- Use transactions for critical operations
- Rate-limit authentication and other guessable endpoints
- Add audit logging with hooks and the file logger
- Follow principle of least privilege for database users
- Keep dependencies updated and re-audit periodically