Skip to content

Security

This document describes Kirak’s built-in security invariants and the rules contributors must not break.


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}'".


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.


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.


  • Passwords are hashed with bcrypt at auth.bcrypt_rounds in kirak.json (default 13) before INSERT. The plaintext password is never logged, stored, or returned.
  • The password field 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, default true) 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)

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 where exp < 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.


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, operation

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


  1. Deny by default – models with an access block deny any unlisted operation.
  2. No access block = no access – every operation is denied for every role. Startup logs a warning; "strict_access": true makes it a startup error. Public operations are opt-in: list guest or *.
  3. Soft-delete filter is runtime-controlled – the deleted_at IS NULL condition is always prepended to fetch/search/count/exists queries on soft-delete models. User-supplied deleted_at filters are stripped before query execution.
  4. RLS applied before user filters – the access policy WHERE fragment is appended before user filters so it cannot be overridden or circumvented.
  5. 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.


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_id with KIRAK_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-signature API with KIRAK_PAYMENT_PAYPAL_WEBHOOK_ID
  • Paddle: HMAC-SHA256 over <ts>:<raw body> with KIRAK_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-hash header must equal KIRAK_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>; (the x-signature header) with KIRAK_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-token header must equal KIRAK_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> (the x-signature header) with KIRAK_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-decoded KIRAK_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_check must be the SHA1 of KIRAK_PAYMENT_TELR_ADVICE_SECRET and the transaction fields, and name this instance’s store; a sale is then confirmed with Telr’s order check before a row completes

Unverified webhooks return 400 without executing any hooks.

Contributor rule: Never process webhook data before signature verification passes.


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.


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=None
  • HttpOnly – inaccessible to JavaScript (prevents XSS token theft)
  • Secure – only sent over HTTPS
  • SameSite=None – required for cross-origin setups (mobile <-> API on different domains)

Contributor rule: Do not set cookies without all three attributes.


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.


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.


  • KIRAK_AUTH_JWT_SECRET_KEY must be >= 32 bytes. Startup raises ConfigurationError if shorter.
  • database.name in kirak.json is required. Startup raises ConfigurationError if empty.
  • KIRAK_AUTH_VERIFICATION_KEY must 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.