Security Guidelines
Critical security practices to protect your IGNIS application.
1. Secret Management
Never hard-code secrets. Use environment variables for all sensitive data.
| Environment | Where to Store Secrets |
|---|---|
| Development | .env file (add to .gitignore) |
| Production | Cloud provider's secret manager (AWS Secrets Manager, Azure Key Vault, etc.) |
Example .env:
APP_ENV_APPLICATION_SECRET=your_strong_random_secret_here
APP_ENV_JWT_SECRET=another_strong_random_secret_here
APP_ENV_POSTGRES_PASSWORD=database_password_hereGenerate strong secrets:
bun -e "console.log(Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64'))"Redaction at Log Time
Never hard-coding a secret is not enough on its own. An options object holding one still ends up in a log line the moment it is passed to logger.info('...: %s', opts). @venizia/ignis-helpers provides two primitives for exactly this:
| Function | Use For | Behavior |
|---|---|---|
redactSecrets(value) | Any object/array being logged | Recursively replaces every value whose key matches a secret-looking name with '[REDACTED]' |
redactUrlCredentials(url) | A connection/broker URL string | Strips the password out of a URL's authority section: mqtts://user:hunter2@broker:8883 becomes mqtts://user:[REDACTED]@broker:8883. A value that doesn't parse as a URL, or one with no password, is returned unchanged |
import { redactSecrets, redactUrlCredentials } from '@venizia/ignis-helpers';
this.logger.info('[connect] Options: %s', redactSecrets(connectionOptions));
this.logger.info('[connect] Broker: %s', redactUrlCredentials(brokerUrl));redactSecrets matches on the key name, case-insensitively, at any depth:
- Options-object spellings:
password,passphrase,secret,token,apiKey,privateKey,credentials,authorization,connectionString, ... - Any
*_token/*Tokenkey:access_token,refresh_token,vaultToken, ... - Vault wire keys:
client_token,secret_id,role_id - HTTP header spellings:
x-api-key,x-vault-token,cookie,set-cookie,proxy-authorization,www-authenticate
It also handles the shapes naive redaction breaks on. An Error is reprojected so its non-enumerable message/stack survive. Cycles become '[Circular]', and buffers/typed arrays are summarized as [Binary N bytes] instead of serialized.
These are the primitives the framework itself uses. Outbound HTTP request configs (NodeFetcher/AxiosFetcher, see Network Helper) and MQTT broker URLs (MQTTClientHelper, see Queue Helper) are both redacted this way before they reach a log line.
WARNING
APP_ENV_LOGGER_DO_REDACT=false turns both functions into the identity function. It is a local-debugging kill-switch and must never be set in production. The check is fail-closed - only the literal string false disables redaction - and is read per call, so it can be flipped at runtime.
2. Input Validation
Always validate incoming data with Zod schemas. IGNIS automatically rejects invalid requests.
import { z } from '@hono/zod-openapi';
import { jsonContent, jsonResponse } from '@venizia/ignis';
const CreateUserRoute = {
method: 'post',
path: '/users',
request: {
body: jsonContent({
schema: z.object({
email: z.string().email(), // Valid email
age: z.number().int().min(18), // Adult only
role: z.enum(['user', 'admin']), // Whitelist
}),
}),
},
responses: jsonResponse({ /* ... */ }),
} as const;Validation happens automatically - invalid requests never reach your handler.
3. Authentication & Authorization
Protect sensitive endpoints with AuthenticateComponent.
Setup:
// application.ts
this.component(AuthenticateComponent);Protect routes:
const SecureRoute = {
path: '/admin/users',
authenticate: { strategies: [Authentication.STRATEGY_JWT] }, // Requires JWT
// ...
};Deep Dive: See Authentication Component for full setup guide.
Access user in protected routes:
import { Authentication, IJWTTokenPayload } from '@venizia/ignis';
import { getError } from '@venizia/ignis-helpers';
const user = c.get(Authentication.CURRENT_USER) as IJWTTokenPayload;
if (!user.roles.some(role => role.identifier === 'admin')) {
throw getError({ statusCode: 403, message: 'Forbidden' });
}4. Protecting Sensitive Data with Hidden Properties
Configure model properties that should never be returned through repository queries. Hidden properties are excluded at the SQL level - they never leave the database.
@model({
type: 'entity',
settings: {
hiddenProperties: ['password', 'apiSecret', 'internalToken'],
},
})
export class User extends BaseRelationalEntity<typeof User.schema> {
static override schema = pgTable('User', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
email: text('email').notNull(),
password: text('password'), // Never returned via repository
apiSecret: text('api_secret'), // Never returned via repository
});
}Why SQL-level exclusion matters:
| Approach | Security Level | Data Exposure Risk |
|---|---|---|
| Post-query filtering (JS) | Low | Data passes through network/memory |
| SQL-level exclusion | High | Data never leaves database |
When you legitimately need hidden data (e.g., password verification), use the connector directly:
// For authentication - access password via connector
const connector = userRepository.connector;
const [user] = await connector
.select({ id: User.schema.id, password: User.schema.password })
.from(User.schema)
.where(eq(User.schema.email, email));Reference: See Hidden Properties for complete documentation.
5. File Upload Security
When handling file uploads, prevent path traversal attacks and ensure safe file handling.
Path Traversal Prevention
Problem: Malicious filenames like ../../../etc/passwd can write files outside intended directories.
Solution: Use sanitizeFilename() to strip dangerous patterns:
import { sanitizeFilename } from '@venizia/ignis-helpers';
// ❌ DANGEROUS - User-controlled filename
const unsafeFilename = req.body.filename; // Could be "../../../etc/passwd"
fs.writeFileSync(`./uploads/${unsafeFilename}`, data);
// ✅ SAFE - Sanitized filename
const safeFilename = sanitizeFilename(req.body.filename);
fs.writeFileSync(`./uploads/${safeFilename}`, data);What sanitizeFilename() does:
- Extracts basename (removes directory paths)
- Removes dangerous characters (
../, special chars) - Replaces consecutive dots with single dot
- Returns
'download'for empty/suspicious patterns
Safe File Download Headers
Use createContentDispositionHeader() for secure download responses:
import { createContentDispositionHeader, sanitizeFilename } from '@venizia/ignis-helpers';
async downloadFile(c: Context) {
const filename = sanitizeFilename(c.req.param('filename'));
const fileBuffer = await fs.readFile(`./uploads/${filename}`);
return new Response(fileBuffer, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': createContentDispositionHeader({
filename: filename,
type: 'attachment',
}),
},
});
}Built-in Multipart Parsing
Use parseMultipartBody() for safe file uploads with automatic sanitization:
import { parseMultipartBody } from '@venizia/ignis-helpers';
async uploadFile(c: Context) {
const files = await parseMultipartBody({
context: c,
storage: 'disk', // or 'memory' for buffer
uploadDir: './uploads', // Target directory
});
// Files are saved with sanitized names: timestamp-random-sanitized_name.ext
return c.json({ uploaded: files.map(f => f.filename) });
}Security features:
- Automatic filename sanitization
- Creates upload directory if missing
- Generates unique filenames (prevents overwrites)
- Returns file metadata (size, mimetype) for validation
Reference: See Request Utility for full API documentation.
6. Secure Dependencies
Regularly audit and update dependencies:
# Check for vulnerabilities
bun audit
# Update dependencies
bun updateCritical packages to keep updated:
hono- Web frameworkjose- JWT handlingdrizzle-orm- Database ORM@venizia/ignis- Framework core
7. CORS Configuration
Configure Cross-Origin Resource Sharing to control which domains can access your API.
Default (Development):
import { cors } from 'hono/cors';
// Allow all origins (ONLY for development)
this.getServer().use('*', cors());Production (Restrictive):
import { cors } from 'hono/cors';
this.getServer().use('/api/*', cors({
origin: ['https://yourdomain.com', 'https://app.yourdomain.com'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposeHeaders: ['X-Request-ID'],
credentials: true,
maxAge: 86400, // Cache preflight for 24 hours
}));Dynamic Origin Validation:
this.getServer().use('/api/*', cors({
origin: (origin) => {
const allowedDomains = ['yourdomain.com', 'yourdomain.io'];
try {
const url = new URL(origin);
return allowedDomains.some(domain => url.hostname.endsWith(domain))
? origin
: null;
} catch {
return null;
}
},
}));WARNING
Never use origin: '*' with credentials: true in production. This is a security vulnerability.
8. Rate Limiting
Protect against brute force attacks and denial of service.
Basic Rate Limiter:
import { createMiddleware } from 'hono/factory';
const rateLimiter = (opts: { windowMs: number; max: number }) => {
const requests = new Map<string, { count: number; resetAt: number }>();
return createMiddleware(async (c, next) => {
const ip = c.req.header('x-forwarded-for') ?? c.req.header('x-real-ip') ?? 'unknown';
const now = Date.now();
const record = requests.get(ip);
if (!record || now > record.resetAt) {
requests.set(ip, { count: 1, resetAt: now + opts.windowMs });
} else if (record.count >= opts.max) {
return c.json({
statusCode: 429,
message: 'Too many requests. Please try again later.',
}, 429);
} else {
record.count++;
}
await next();
});
};
// Apply to sensitive endpoints
const server = this.getServer();
server.use('/api/auth/login', rateLimiter({ windowMs: 15 * 60 * 1000, max: 5 }));
server.use('/api/auth/register', rateLimiter({ windowMs: 60 * 60 * 1000, max: 10 }));
server.use('/api/*', rateLimiter({ windowMs: 60 * 1000, max: 100 }));Recommended Limits:
| Endpoint | Window | Max Requests | Reason |
|---|---|---|---|
/auth/login | 15 min | 5 | Prevent brute force |
/auth/register | 1 hour | 10 | Prevent spam accounts |
/auth/forgot-password | 1 hour | 3 | Prevent email flooding |
/api/* (general) | 1 min | 100 | General protection |
Production Recommendation: Use Redis-backed rate limiting for distributed deployments:
import { RedisSingleHelper } from '@venizia/ignis-helpers';
// Rate limiter with Redis for multi-instance deployments
const redisHelper = new RedisSingleHelper({
name: 'rate-limiter',
host: process.env.APP_ENV_REDIS_HOST ?? 'localhost',
port: process.env.APP_ENV_REDIS_PORT ?? '6379',
password: process.env.APP_ENV_REDIS_PASSWORD ?? '',
});
const distributedRateLimiter = async (opts: { key: string; max: number; windowSeconds: number }) => {
const current = await redisHelper.incr({ key: opts.key });
if (current === 1) {
await redisHelper.expire({ key: opts.key, seconds: opts.windowSeconds });
}
return current <= opts.max;
};9. SQL Injection Prevention
Drizzle ORM automatically parameterizes queries, protecting against SQL injection. However, raw queries require care.
Safe (Parameterized):
// ✅ Repository methods are safe - queries are parameterized
await userRepository.find({
filter: { where: { email: userInput } },
});
// ✅ Drizzle query builder is safe
await db.select().from(users).where(eq(users.email, userInput));
// ✅ sql`` template with placeholders is safe
await db.execute(sql`SELECT * FROM users WHERE email = ${userInput}`);Dangerous (String Interpolation):
// ❌ NEVER use string interpolation in raw SQL
const query = `SELECT * FROM users WHERE email = '${userInput}'`;
await db.execute(sql.raw(query)); // Vulnerable to SQL injection!
// ❌ NEVER build WHERE clauses with string concatenation
const condition = `status = '${status}' AND role = '${role}'`;If You Must Use Dynamic SQL:
// Use parameterized queries with sql.raw only for table/column names
const tableName = allowedTables.includes(input) ? input : 'default_table';
await db.execute(sql`SELECT * FROM ${sql.identifier(tableName)} WHERE id = ${id}`);10. Security Headers
Add security headers to protect against common attacks:
import { secureHeaders } from 'hono/secure-headers';
// Add security headers to all responses
this.getServer().use('*', secureHeaders({
// Prevent clickjacking
xFrameOptions: 'DENY',
// Prevent MIME type sniffing
xContentTypeOptions: 'nosniff',
// Enable XSS filter
xXssProtection: '1; mode=block',
// Control referrer information
referrerPolicy: 'strict-origin-when-cross-origin',
// Content Security Policy
contentSecurityPolicy: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
}));11. Request Size Limits
Prevent denial of service through large payloads:
import { bodyLimit } from 'hono/body-limit';
// Limit request body size
this.getServer().use('/api/*', bodyLimit({
maxSize: 1024 * 1024, // 1MB for general API
onError: (c) => c.json({ message: 'Request body too large' }, 413),
}));
// Allow larger uploads for file endpoints
this.getServer().use('/api/upload/*', bodyLimit({
maxSize: 50 * 1024 * 1024, // 50MB for file uploads
}));12. Logging Security Events
Log security-relevant events for monitoring and forensics:
import { BaseService } from '@venizia/ignis';
import { getError } from '@venizia/ignis-helpers';
export class AuthService extends BaseService {
async login(email: string, password: string, context: Context) {
const ip = context.req.header('x-forwarded-for') ?? 'unknown';
const userAgent = context.req.header('user-agent') ?? 'unknown';
const user = await this.userRepository.findByEmail(email);
if (!user || !await this.verifyPassword(password, user.password)) {
// Log failed attempt
this.logger.warn('[login] Failed login attempt | email: %s | ip: %s | userAgent: %s',
email, ip, userAgent);
throw getError({ statusCode: 401, message: 'Invalid credentials' });
}
// Log successful login
this.logger.info('[login] Successful login | userId: %s | ip: %s', user.id, ip);
return this.generateToken(user);
}
}Error Responses Are Hardened in Production
The global error handler decides what a client sees by environment, and it is fail-closed: an unset or unrecognized NODE_ENV is treated as production. Only NODE_ENV values in the development set relax it.
| In production | Behavior |
|---|---|
| Unexpected errors | Replaced with a generic "Internal Server Error" - raw text may carry SQL, schema names or connection details |
| Database constraint errors (400) | Base message only; the driver's Detail: (which echoes row values like emails), Table: and Constraint: are stripped |
details.stack / details.cause | Omitted |
Deliberate getError messages are always returned verbatim, in every environment - so never put internal detail in one. Use requestId plus the server log to diagnose what the response no longer shows. See Error Handling.
Events to Log:
- Failed login attempts
- Successful logins
- Password changes
- Permission changes
- Suspicious activity (rate limit hits, invalid tokens)
- Admin actions
NOTE
When logging a request or connection config that might carry credentials, wrap it in redactSecrets() (or redactUrlCredentials() for a URL) rather than logging it raw. See Redaction at Log Time above. This is what the framework's own outbound HTTP and MQTT logging already does automatically.
Security Checklist
Before deploying to production, verify:
| Category | Check |
|---|---|
| Secrets | All secrets in environment variables, not in code |
| Auth | JWT tokens have reasonable expiration (15min - 24h) |
| Input | All user input validated with Zod schemas |
| CORS | Specific origins configured, not * |
| Rate Limiting | Applied to auth endpoints and general API |
| Headers | Security headers configured |
| Logging | Security events logged for monitoring |
| Dependencies | No known vulnerabilities (bun audit) |
| HTTPS | TLS configured for production |
| Hidden Data | Sensitive fields use hiddenProperties |
| Redaction | APP_ENV_LOGGER_DO_REDACT is unset (never false) |
| Error Leakage | NODE_ENV set to a production value so responses are sanitized |
See Also
- Authentication Component - JWT setup and configuration
- Common Pitfalls - Security-related mistakes to avoid
- Deployment Strategies - Secure deployment practices