Security
This document describes Kirak’s built-in security invariants and the rules contributors must not break.
SQL Injection Prevention
Section titled “SQL Injection Prevention”All SQL values are parameterized. No user input is ever interpolated directly into a SQL string.
The query_builder.py module always uses dialect-aware placeholders:
- MySQL:
%s - PostgreSQL:
$1,$2, …
Filter keys (field names and operator suffixes) are parsed by parse_field_operator() and matched against the model’s field list. Unknown field names are silently ignored – they cannot reach the SQL query.
Contributor rule: Any new operation that builds a SQL query must use dialect.placeholder(index) or the build_condition() / build_conditions() helpers. Never concatenate f"WHERE {field} = '{value}'".
RLS Condition Injection Prevention
Section titled “RLS Condition Injection Prevention”Row-level security conditions in access.condition fields are validated at startup against a strict grammar (_SAFE_CONDITION_RE in access.py). Any condition outside the grammar causes ConfigurationError and prevents the server from starting.
The grammar permits: field operator {variable_or_literal} joined by AND/OR. It rejects subqueries, function calls, and raw values embedded without a placeholder.
At runtime, {variable} placeholders are resolved from the JWT claims and passed as parameterized values – never interpolated into the SQL string.
Algorithm Confusion Attack Prevention
Section titled “Algorithm Confusion Attack Prevention”JWT tokens are validated with an explicit allowlist of HMAC algorithms:
_ALLOWED = {"HS256", "HS384", "HS512"}Asymmetric algorithms (RS256, ES256, etc.) and the none algorithm are unconditionally rejected. This prevents the algorithm confusion attack where an attacker changes the algorithm from RS256 to HS256 and signs with the public key.
MCP servers authenticate their callers with the same code, so the same allowlist applies.
Contributor rule: Never pass algorithms=["RS256", "HS256"] or algorithms=jwt.algorithms.get_default_algorithms() to jwt.decode(). Always use the explicit HS-only allowlist.
Password Security
Section titled “Password Security”- Passwords are hashed with bcrypt at
auth.bcrypt_roundsin kirak.json (default 13) before INSERT. The plaintext password is never logged, stored, or returned. - The
passwordfield type in models ensures password fields are never returned in fetch/search responses. - Minimum password length is 12 characters, enforced unconditionally regardless of the complexity flag.
auth.password_complexity(kirak.json, defaulttrue) additionally requires at least one uppercase letter, one lowercase letter, one digit, and one special character. - Password verification uses
bcrypt.checkpw()– constant-time comparison. No timing oracle.
Minimum recommended rounds:
- Development: 12
- Production: 13+ (each additional round doubles compute time)
Token Blacklisting
Section titled “Token Blacklisting”Logout blacklists the token’s jti (JWT ID). Every authenticated request checks the blacklist before processing. Blacklisted tokens return HTTP 401 immediately.
- Without Redis: DB table
auth_token_blacklist. Entries are not automatically purged – add a periodic job to delete rows whereexp < now(). - With Redis: key
kirak:bl:{jti}with TTL = token’s remaining lifetime. Auto-purges.
Contributor rule: Do not skip the blacklist check in new authenticated operations.
Error Message Sanitization
Section titled “Error Message Sanitization”DatabaseError deliberately hides internal details:
raise DatabaseError("fetch", model_name)# Client sees: "Database error during fetch on posts"# Server log contains: full SQL error, stack trace, model name, operationSQLAlchemy errors, asyncmy errors, and asyncpg errors are never forwarded to the client. The full error is logged to the module’s rotating log file.
Contributor rule: Catch database exceptions and re-raise as DatabaseError. Never return {"error": str(e)} for a database exception.
Access Control Invariants
Section titled “Access Control Invariants”- Deny by default – models with an
accessblock deny any unlisted operation. - No access block = no access – every operation is denied for every role. Startup logs a warning;
"strict_access": truemakes it a startup error. Public operations are opt-in: listguestor*. - Soft-delete filter is runtime-controlled – the
deleted_at IS NULLcondition is always prepended to fetch/search/count/exists queries on soft-delete models. User-supplieddeleted_atfilters are stripped before query execution. - RLS applied before user filters – the access policy WHERE fragment is appended before user filters so it cannot be overridden or circumvented.
- Field-level security runs after operation-level – a user denied at the operation level never reaches field evaluation.
Contributor rule: Never allow the caller to supply a value that ends up directly in the WHERE clause without going through build_condition(). Never allow soft-delete models to return deleted records through the standard fetch/search path.
Webhook Signature Verification
Section titled “Webhook Signature Verification”Payment webhooks are verified with the provider’s signature before any processing:
- Stripe:
stripe.Webhook.construct_event(body, signature, KIRAK_PAYMENT_STRIPE_WEBHOOK_SECRET) - Razorpay: HMAC-SHA256 over
order_id|payment_idwithKIRAK_PAYMENT_RAZORPAY_WEBHOOK_SECRET - Square: Base64 HMAC-SHA256 over the notification URL plus raw body with
KIRAK_PAYMENT_SQUARE_WEBHOOK_SIGNATURE_KEY - PayPal: verified online through PayPal’s
verify-webhook-signatureAPI withKIRAK_PAYMENT_PAYPAL_WEBHOOK_ID - Paddle: HMAC-SHA256 over
<ts>:<raw body>withKIRAK_PAYMENT_PADDLE_WEBHOOK_SECRET - Paystack: HMAC-SHA512 over the raw body with
KIRAK_PAYMENT_PAYSTACK_SECRET_KEY; the charge is then re-verified with Paystack’s API before it completes - Flutterwave: the
verif-hashheader must equalKIRAK_PAYMENT_FLUTTERWAVE_SECRET_HASH(a static secret, compared in constant time, not a signature over the body), so every charge, refund, subscription cancellation and chargeback is re-read from Flutterwave’s API before a row changes or an event is reported - Mercado Pago: hex HMAC-SHA256 of
id:<data.id>;request-id:<x-request-id>;ts:<ts>;(thex-signatureheader) withKIRAK_PAYMENT_MERCADOPAGO_WEBHOOK_SECRET; notifications carry only an id, so the payment, subscription or chargeback is re-read from Mercado Pago’s API before a row changes - Xendit: the
x-callback-tokenheader must equalKIRAK_PAYMENT_XENDIT_CALLBACK_TOKEN(a static token, compared in constant time, not a signature over the body), so every invoice and refund is re-read from Xendit’s API before a row changes - Airwallex: hex HMAC-SHA256 of
<x-timestamp><raw body>(thex-signatureheader) withKIRAK_PAYMENT_AIRWALLEX_WEBHOOK_SECRET; the paid link, its payment intent and refunds are re-read from Airwallex’s API before a row changes - Omise: hex HMAC-SHA256 of
<Omise-Signature-Timestamp>.<raw body>with the base64-decodedKIRAK_PAYMENT_OMISE_WEBHOOK_SECRET(without a secret, the event is read back from Omise’s API); the charge, refund or dispute is then re-read before a row changes - Telr: the transaction advice’s
tran_checkmust be the SHA1 ofKIRAK_PAYMENT_TELR_ADVICE_SECRETand the transaction fields, and name this instance’s store; a sale is then confirmed with Telr’s ordercheckbefore a row completes
Unverified webhooks return 400 without executing any hooks.
Contributor rule: Never process webhook data before signature verification passes.
Encryption at Rest
Section titled “Encryption at Rest”Social provider tokens (extra OAuth data stored in auth_social) and MFA TOTP secrets (auth_mfa.totp_secret) are encrypted with AES-256-GCM (kirak/auth/social/encryption.py) before storage, keyed by KIRAK_AUTH_ENCRYPTION_KEY, with the owning user’s id bound in as authenticated data so a copied ciphertext can’t be decrypted under a different user’s row. If the key is unset, Kirak falls back to a SHA-256 derivation of KIRAK_AUTH_JWT_SECRET_KEY and logs a startup warning – set KIRAK_AUTH_ENCRYPTION_KEY explicitly in production so this encryption doesn’t depend on the JWT secret.
KIRAK_AUTH_VERIFICATION_KEY (a Fernet key) is used for email verification and password reset links – it is unrelated to the encryption above.
API keys are stored only as an HMAC-SHA256 hash keyed by KIRAK_AUTH_API_KEY_SECRET. Set it in production: without it the hash is keyed by KIRAK_AUTH_JWT_SECRET_KEY, and rotating the JWT secret invalidates every API key.
HttpOnly Cookie Security
Section titled “HttpOnly Cookie Security”When cookie-based sessions are enabled (cookie_name set in kirak.json’s auth section – this is a manifest setting, not an environment variable), the cookie is always set with:
HttpOnly; Secure; SameSite=NoneHttpOnly– inaccessible to JavaScript (prevents XSS token theft)Secure– only sent over HTTPSSameSite=None– required for cross-origin setups (mobile <-> API on different domains)
Contributor rule: Do not set cookies without all three attributes.
Rate Limiting
Section titled “Rate Limiting”Hard-coded rate limits on auth endpoints – their numbers cannot be adjusted:
- Login: 5/min per IP, 3/min per email
- Password reset: 3/hour per email
- OTP request: 3/hour per phone
These are security invariants, not tunable configuration. The one thing that does affect them is the global rate_limit.enabled flag (kirak.json) – setting it false turns off these limits along with every per-model CRUD limit, which should never happen in production. A number of other auth endpoints also carry hardcoded per-IP limits not listed above – see Rate Limiting for the complete table.
By default, every model’s CRUD routes are rate-limited too, even without an explicit rate_limit block – the global default (100 requests/60s per IP) applies automatically. A model can only opt out by setting "rate_limit": {} explicitly.
IP spoofing: rate-limit IP resolution trusts X-Forwarded-For/X-Real-IP only from connections whose direct socket address is in rate_limit.trusted_proxy_ips. If you run behind a reverse proxy or load balancer and don’t set this, every request appears to come from the proxy’s IP and shares one counter – if you do set it to include something you don’t actually control (or fail to keep it in sync with your infra), a client can spoof X-Forwarded-For to bypass IP-based limits entirely. Only list IPs of proxies you control that already strip/overwrite client-supplied forwarding headers.
Security Response Headers
Section titled “Security Response Headers”Every response carries a fixed set of security headers, set unconditionally by SecurityHeadersMiddleware (kirak/middleware/security_headers.py) – there is no configuration flag to disable them:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
X-XSS-Protection |
1; mode=block |
Referrer-Policy |
strict-origin-when-cross-origin |
A 10 MB request body-size limit is also enforced unconditionally, ahead of any route handler – see Middleware.
Dependency Security
Section titled “Dependency Security”KIRAK_AUTH_JWT_SECRET_KEYmust be >= 32 bytes. Startup raisesConfigurationErrorif shorter.database.nameinkirak.jsonis required. Startup raisesConfigurationErrorif empty.KIRAK_AUTH_VERIFICATION_KEYmust be a valid Fernet key. Invalid key format raises a clear error at first use.
Contributor rule: Validate all security-critical configuration at startup, not at first use in a request handler.