Table of contents
A fintech platform that handles money is a high-value target. When we designed Trulipay's security model, we started with a simple question: if an attacker got read access to our database, what would they find? The answer needed to be: not much.
Here's the security architecture we built.
Threat Modeling First
Before writing any security code, we identified our primary threat scenarios:
- Database breach — attacker reads all rows in our DB
- Environment variable leak —
.envfiles or CI secrets exposed via logs or misconfiguration - Man-in-the-middle — traffic intercepted between client and server
- Insider threat — a developer or contractor with DB access exfiltrating data
- Dependency compromise — a malicious package in
node_modules
Each countermeasure maps to one or more of these scenarios. Security without a threat model produces the wrong controls in the wrong places.
AES-256-GCM for Sensitive Data at Rest
Payment-related data that must be stored (but not queried) is encrypted before it hits the database. We use AES-256-GCM, which provides both confidentiality and authenticity:
import crypto from "crypto";
const ENCRYPTION_KEY = Buffer.from(process.env.DB_ENCRYPTION_KEY!, "hex");
// Must be exactly 32 bytes (256 bits)
export function encrypt(plaintext: string): string {
const iv = crypto.randomBytes(12); // 96-bit IV for GCM
const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag(); // 128-bit authentication tag
// Store: iv (12 bytes) + authTag (16 bytes) + ciphertext
return Buffer.concat([iv, authTag, encrypted]).toString("base64");
}
export function decrypt(encryptedData: string): string {
const buf = Buffer.from(encryptedData, "base64");
const iv = buf.subarray(0, 12);
const authTag = buf.subarray(12, 28);
const ciphertext = buf.subarray(28);
const decipher = crypto.createDecipheriv("aes-256-gcm", ENCRYPTION_KEY, iv);
decipher.setAuthTag(authTag);
return decipher.update(ciphertext) + decipher.final("utf8");
}The GCM mode is important: the authTag detects any tampering with the ciphertext. A pure AES-CBC implementation without MAC would be vulnerable to bit-flipping attacks.
We encrypt: card holder names, billing addresses, bank account last-four digits (as full strings), and any PII that isn't needed for querying or indexing.
We do not encrypt: payment amounts, currency codes, transaction timestamps. These need to be queryable and indexable. Encrypting them would break every analytics query.
Password Hashing with bcrypt
User passwords are never stored. We store bcrypt hashes with a cost factor of 12:
import bcrypt from "bcrypt";
const BCRYPT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}Cost factor 12 means roughly 300ms per hash on our server hardware—slow enough to make brute-force impractical, fast enough for login to feel instant.
We also enforce password policy at the application layer before hashing:
function validatePasswordStrength(password: string): void {
if (password.length < 12) throw new AppError("Password must be at least 12 characters");
if (!/[A-Z]/.test(password)) throw new AppError("Password must contain uppercase letter");
if (!/[0-9]/.test(password)) throw new AppError("Password must contain a number");
if (!/[^A-Za-z0-9]/.test(password)) throw new AppError("Password must contain a special character");
}Never validate password complexity on the client only—it's trivially bypassed.
HTTPS and HSTS
All traffic is HTTPS-only. We enforce this at two levels:
Application level — redirect HTTP to HTTPS:
app.use((req, res, next) => {
if (req.headers["x-forwarded-proto"] !== "https" && process.env.NODE_ENV === "production") {
return res.redirect(301, `https://${req.hostname}${req.url}`);
}
next();
});HTTP Strict Transport Security (HSTS) — tells browsers to always use HTTPS for the next year, even if they type http://:
app.use(
helmet.hsts({
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true,
preload: true, // eligible for browser preload lists
})
);We also include a full helmet() setup: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, and X-XSS-Protection.
Minimal Secret Storage
The biggest source of credential leaks in most systems isn't the database—it's .env files committed to git, secrets logged in error messages, or environment variables visible in process listings.
Our rules:
Never log secrets:
// BAD
logger.error("Payment failed", { apiKey: process.env.STRIPE_SECRET_KEY, error });
// GOOD
logger.error("Payment failed", { gateway: "stripe", error: error.message });Validate secrets at startup, not lazily:
function assertRequiredSecrets() {
const required = [
"DB_ENCRYPTION_KEY",
"STRIPE_SECRET_KEY",
"WEBHOOK_SECRET",
"JWT_SECRET",
];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
throw new Error(`Missing required secrets: ${missing.join(", ")}`);
}
// Validate key lengths
if (Buffer.from(process.env.DB_ENCRYPTION_KEY!, "hex").length !== 32) {
throw new Error("DB_ENCRYPTION_KEY must be 32 bytes (64 hex characters)");
}
}Failing loud at startup is better than failing silently at runtime when a payment is being processed.
Rotate keys without downtime:
We keep two active encryption keys: current and previous. When we rotate, new data is encrypted with current. Old data encrypted with previous can still be decrypted. After a rotation period, we re-encrypt old records with the new key and retire previous.
const KEYS = {
current: Buffer.from(process.env.ENCRYPTION_KEY_CURRENT!, "hex"),
previous: Buffer.from(process.env.ENCRYPTION_KEY_PREVIOUS ?? process.env.ENCRYPTION_KEY_CURRENT!, "hex"),
};
export function decryptAny(encryptedData: string): string {
// Try current key first, fall back to previous
try {
return decryptWithKey(encryptedData, KEYS.current);
} catch {
return decryptWithKey(encryptedData, KEYS.previous);
}
}Database Security Layers
We apply principle of least privilege at the database layer:
- The application user has SELECT, INSERT, UPDATE on specific tables—no DROP, no TRUNCATE.
- The analytics user has SELECT only on the reporting schema.
- Migrations run under a separate migration user with CREATE TABLE and ALTER TABLE, only during deployment.
We never connect to production databases from development machines. All production DB access goes through an SSH bastion with full audit logging.
Key Takeaways
- AES-256-GCM over AES-CBC—the authentication tag catches ciphertext tampering; pure CBC doesn't.
- Encrypt for data breach resilience, not query performance—don't encrypt fields you query; you'll break your entire analytics layer.
- bcrypt cost factor 12 is a good production baseline—recalibrate every 2-3 years as hardware gets faster.
- HSTS with
preload: trueis the strongest browser-level enforcement;includeSubDomainsmatters if you have subdomains. - Validate all secrets at startup—a missing secret at 2am during a payment surge is worse than a clean startup failure during deployment.
- Key rotation is not optional—design the decrypt path to handle multiple key versions from day one.