Security & hardening
Most of Iskra’s defaults are the safe choice, so the easy path is also the safe one. Not all of them: some features are open until you configure them (a SocketDriver accepts any origin, any room and any topic; the OpenAPI docs are public). This guide walks through the protections that are on out of the box, says where a default is open, and names the options that close it.
Secrets
Section titled “Secrets”The auth/web secret must be at least 32 characters. createBetterAuth throws at boot if it is missing or too short, so a weak secret never reaches production silently:
// packages/auth-kit — createBetterAuth validates length// throws: "auth secret must be at least 32 characters; received 8"In production (any NODE_ENV but development and test, unset included) it also refuses a secret that is still a sample value, one containing change-me, dev-secret, dev-only, your-secret or placeholder (in any case, with or without -, _, . or spaces). Such a secret is public, and it signs the session cookie cache, which is trusted without a database lookup: anyone could forge a session. The SessionFeature and CsrfFeature secrets need 32 characters too.
Always pull secrets from the environment, never hardcode them:
export default { auth: { secret: process.env.AUTH_SECRET, // >= 32 chars, or boot fails },};The logger redacts sensitive fields automatically, at any depth. Keys such as password, pass, apiKey, apiSecret, token, authToken, accessToken, secret, clientSecret, privateKey, authorization, cookie, setCookie and sessionId, and keys ending in password, secret, token, apiKey, secretKey, privateKey or accessKey, are replaced with [REDACTED] before anything is written, as are config.env and any *.data. Keys are compared without case, - or _, so api_key, X-API-Key and DB_PASSWORD match too:
app.logger.info({ password: 'hunter2', headers: { 'x-api-key': 'abc' } }, 'login');// → { password: '[REDACTED]', headers: { 'x-api-key': '[REDACTED]' } }The same applies to the bindings of child loggers and to the fields of logged errors (an HTTP client’s config.headers.Authorization, say). In messages, and in error messages and stacks, the password of a scheme://user:password@host URL and secret-looking query parameters (?authToken=, &X-Amz-Signature=) are masked.
CSRF protection
Section titled “CSRF protection”The CSRF feature uses an HMAC-signed double-submit cookie (OWASP pattern). The token is <random>.<hmac>, and verification uses a constant-time comparison (timingSafeEqual) so signatures can’t be brute-forced by timing:
import { CsrfFeature } from '@iskra-bun/web-kit';
new CsrfFeature({ secret: process.env.CSRF_SECRET, // required, >= 32 characters, or it throws trustedOrigins: ['https://admin.example.com'], // other origins whose pages may post // cookieName defaults to "__Host-csrf" ("_csrf" if not Secure), headerName to "X-CSRF-Token"});The token alone does not say who submitted it: a sibling subdomain can set cookies for the parent domain, so it could plant a token it knows and submit it with the victim’s session, and SameSite does not stop a same-site request. So:
- A state-changing request from another origin is rejected: an
Originthat is neither the app’s own nor intrustedOriginsgets 403, and so does a request withoutOriginwhoseSec-Fetch-Siteiscross-site. - The cookie is
__Host-csrfwhile it is Secure (the default), a name only the app’s own host can set. - With
SessionFeature, the token is signed together with the stored session’s ID, andregenerateSession()issues a new one, so a token from another session is worthless.
Cookies default to httpOnly, secure, sameSite: 'Strict'. AuthFeature has a disableCSRFCheck kill-switch (for better-auth’s own check) for local development, but it is ignored outside development — even if a config ships with it enabled, it is neutralized unless NODE_ENV is development or test (an unset NODE_ENV counts as production):
const disableCSRFCheck = isDevelopmentEnv() ? this.config.disableCSRFCheck === true : false;WebSocket authorization
Section titled “WebSocket authorization”socket-kit is open by default: a SocketDriver without options accepts connections from any origin without authentication (it logs a warning at start), lets any client join any room and publish to any topic, global included, and re-emits every unhandled event on the app bus. Lock it down with allowedOrigins and authenticate (the handshake), canJoin and canPublish (per connection), and allowedEvents:
import { SocketDriver } from '@iskra-bun/socket-kit';
new SocketDriver({ allowedOrigins: ['https://app.example.com'], // other Origins get 403 (cross-site WebSocket hijacking) authenticate: (req) => redeemTicket(new URL(req.url).searchParams.get('ticket')), // null => 401 canJoin: (connection, room) => isMember(connection.data.auth, room), canPublish: (connection, topic) => canWrite(connection.data.auth, topic), allowedEvents: ['presence:ping'], maxPayloadLength: 16 * 1024, // 16 KiB default — caps frame size rateLimit: 100, // messages per window (default 100) rateWindowMs: 1000, // window length (default 1000ms)});Do not authenticate the handshake with a long-lived token in the URL (?token=<session or JWT>): the URL is written to the access logs of every proxy and load balancer on the way. Issue a short-lived, single-use ticket from an authenticated HTTP route and redeem it in authenticate, or use the Sec-WebSocket-Protocol header or the session cookie (the cookie needs allowedOrigins); see Socket Kit.
maxPayloadLength bounds frame size and the per-connection rate limit drops the frames over a connection’s message budget, protecting against floods (it logs one warning per window, not one per frame). The hooks are only called with string rooms and topics: a handler that passes a client’s value on cannot slip ["global"] past a deny-list such as topic !== 'global'.
The SMTP provider is TLS-by-default. STARTTLS is required on non-465 ports (requireTLS: true) and certificates are always validated (rejectUnauthorized: true):
// mailer-kit SMTP — TLS enforced, certs validatednodemailer.createTransport({ host, port, secure, // implicit TLS on 465 requireTLS: secure ? undefined : true, tls: { rejectUnauthorized: true },});The Mailgun provider applies a strict header allowlist and strips CRLF from values to prevent header injection. Only these headers are accepted: in-reply-to, references, list-unsubscribe, list-unsubscribe-post, list-id, x-mailgun-variables, x-mailgun-tag. Anything else throws:
// throws: Header "x-evil" is not allowedSet Reply-To with message.replyTo, whose address is checked like a recipient’s; a reply-to in headers throws Header "reply-to" is not allowed: use message.replyTo.
Storage
Section titled “Storage”The local adapter blocks path traversal: keys are sanitized and resolved against basePath, and any key that escapes the storage root is rejected:
// storage-kit local adapter// throws: "Path escapes storage root: ../../etc/passwd"The S3 adapter refuses plaintext (http://) endpoints unless you explicitly opt out with useSSL: false:
// throws: "Refusing plaintext S3 endpoint; set useSSL:false to override"new S3Adapter({ connection: { endpoint: 'https://s3.example.com' /* useSSL defaults on */ },});Child processes
Section titled “Child processes”A process-kit child gets a minimal environment by default: PATH, HOME, the locale, TZ, the temp dir, NODE_ENV and the like, plus its env. DATABASE_URL, AUTH_SECRET, cloud keys and whatever was loaded from .env stay in the app, so a child running third-party code cannot read them; list the variables a child needs in inheritEnv (true passes them all). send() refuses messages once more than maxPendingStdinBytes (8 MiB) wait for a child that is not reading its stdin, instead of holding them all in memory.
Background jobs
Section titled “Background jobs”BullMQ keeps finished jobs in Redis with their data. worker-kit keeps the last 1000 completed jobs and the failed ones of the last 7 days (up to 5000) unless removeOnComplete / removeOnFail say otherwise, so job payloads (emails, tokens, personal data) do not pile up forever. When you handle worker:dead-letter, log the job’s ids through the app logger, not its data.
Telemetry
Section titled “Telemetry”Spans export the URL of each request, and query strings often carry credentials. The OpenTelemetry setup of core (HTTP auto-instrumentation) and OtelTracingFeature export them with the value of secret-looking parameters (token, access_token, api_key, code, state, sig, X-Amz-Signature…) replaced by REDACTED, and OtelTracingFeature also masks better-auth’s /reset-password/<token>; redactedQueryParams sets the list. The startup log names the OTLP endpoint by its origin only, and warns when a remote endpoint is plain http://. On a service that faces the internet, set ignoreIncomingTraceContext: true on OtelTracingFeature so a client’s traceparent cannot force sampling or attach its requests to a trace of its choosing.
Plugins and configuration
Section titled “Plugins and configuration”Kits, drivers, plugins and web features run with full access to the app, so the Kernel keeps their composition from weakening it: a second feature with a name already registered (a helper called csrf, a second RateLimitFeature) is refused instead of silently replacing the first, and routes added before initialize(), or by a feature in initialize() instead of routes(), make initialize() fail instead of running without the security headers and the other features’ middleware.
new App() without a config reads app.config.* from the working directory (and .env). It no longer reads .apprc files, and it does not download extends layers from github:, gitlab: or https:// sources (local extends paths still work). For a CLI or desktop binary that runs in directories you don’t control, pass the config to new App({ ... }).
HTTP hardening
Section titled “HTTP hardening”Rate limits count requests per client IP: the socket address or, with new Kernel({ trustProxy: n }), the address the proxies put in clientIpHeader (X-Forwarded-For by default, X-Real-IP if your proxy sets that one). Only that header is read, so a client cannot choose which one counts; IPv6 clients count by /64, and the in-memory counters are capped and swept.
CorsFeature with credentials: true needs the allowed origins listed: a wildcard origin makes initialize() throw, instead of sending Access-Control-Allow-Origin: * (which browsers reject with credentials) and tempting apps to reflect every origin.
The health endpoints hide internal details by default. includeDetails defaults to false, so feature lists, DB checks, raw errors (which may embed connection strings), the names of readiness checks (which can name hosts) and the uptime are never serialized to clients unless you explicitly opt in; the status code still tells a load balancer or orchestrator what it needs:
import { HealthCheckFeature } from '@iskra-bun/web-kit';
new HealthCheckFeature({ includeDetails: false }); // defaultThe OpenAPI docs (/openapi.json, /docs) are public by default. They load one pinned Scalar release with its Subresource Integrity hash, under a Content-Security-Policy that only lets the page talk to the app and the spec’s servers; gate them with authorize(c) (middleware added after initialize() does not cover them) or turn them off with docs: false.
API keys are hashed with SHA-256 before being used as cache keys, so the plaintext secret is never persisted where a cache dump could leak it:
// api-key featureconst hash = createHash('sha256').update(key).digest('hex');const cacheKey = `apikey:${hash}`;OAuth/OIDC flows enable PKCE by default. Disabling it exposes authorization-code interception and requires an explicit pkce: false:
// auth-kit OIDC — PKCE on unless explicitly disabledpkce: oidcConfig.pkce !== undefined ? oidcConfig.pkce : true,