Skip to content

Security Guidelines

Critical security practices to protect your IGNIS application.

1. Secret Management

Never hard-code secrets. Use environment variables for all sensitive data.

EnvironmentWhere to Store Secrets
Development.env file (add to .gitignore)
ProductionCloud provider's secret manager (AWS Secrets Manager, Azure Key Vault, etc.)

Example .env:

bash
APP_ENV_APPLICATION_SECRET=your_strong_random_secret_here
APP_ENV_JWT_SECRET=another_strong_random_secret_here
APP_ENV_POSTGRES_PASSWORD=database_password_here

Generate strong secrets:

bash
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:

FunctionUse ForBehavior
redactSecrets(value)Any object/array being loggedRecursively replaces every value whose key matches a secret-looking name with '[REDACTED]'
redactUrlCredentials(url)A connection/broker URL stringStrips 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
typescript
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 / *Token key: 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.

typescript
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:

typescript
// application.ts
this.component(AuthenticateComponent);

Protect routes:

typescript
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:

typescript
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.

typescript
@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:

ApproachSecurity LevelData Exposure Risk
Post-query filtering (JS)LowData passes through network/memory
SQL-level exclusionHighData never leaves database

When you legitimately need hidden data (e.g., password verification), use the connector directly:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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:

bash
# Check for vulnerabilities
bun audit

# Update dependencies
bun update

Critical packages to keep updated:

  • hono - Web framework
  • jose - JWT handling
  • drizzle-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):

typescript
import { cors } from 'hono/cors';

// Allow all origins (ONLY for development)
this.getServer().use('*', cors());

Production (Restrictive):

typescript
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:

typescript
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:

typescript
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:

EndpointWindowMax RequestsReason
/auth/login15 min5Prevent brute force
/auth/register1 hour10Prevent spam accounts
/auth/forgot-password1 hour3Prevent email flooding
/api/* (general)1 min100General protection

Production Recommendation: Use Redis-backed rate limiting for distributed deployments:

typescript
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):

typescript
// ✅ 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):

typescript
// ❌ 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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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 productionBehavior
Unexpected errorsReplaced 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.causeOmitted

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:

CategoryCheck
SecretsAll secrets in environment variables, not in code
AuthJWT tokens have reasonable expiration (15min - 24h)
InputAll user input validated with Zod schemas
CORSSpecific origins configured, not *
Rate LimitingApplied to auth endpoints and general API
HeadersSecurity headers configured
LoggingSecurity events logged for monitoring
DependenciesNo known vulnerabilities (bun audit)
HTTPSTLS configured for production
Hidden DataSensitive fields use hiddenProperties
RedactionAPP_ENV_LOGGER_DO_REDACT is unset (never false)
Error LeakageNODE_ENV set to a production value so responses are sanitized

See Also