Skip to content

Troubleshooting

Common errors and how to fix them.


ConfigurationError: KIRAK_AUTH_JWT_SECRET_KEY must be at least 32 bytes

Section titled “ConfigurationError: KIRAK_AUTH_JWT_SECRET_KEY must be at least 32 bytes”

The secret key is too short or missing.

Terminal window
# Generate a secure key
python -c "import secrets; print(secrets.token_hex(32))"

Set it in .env:

KIRAK_AUTH_JWT_SECRET_KEY=<generated-value>

ConfigurationError: database.name is required

Section titled “ConfigurationError: database.name is required”

The database name is not set in the database block of kirak.json. Only the password is an environment variable.

"database": { "host": "localhost", "name": "myapp", "user": "myuser" }
DB_PASSWORD=mypassword

If you upgraded from a version that read DB_NAME, DB_HOST and the other DB_* variables (except DB_PASSWORD), move them into kirak.json; startup logs a warning naming each one that is still set.


An access.condition in models.json uses unsupported syntax.

The grammar only allows: field operator {variable} joined by AND/OR.

// Invalid -- subquery not allowed
"condition": "id IN (SELECT id FROM allowed_ids WHERE user_id = {sub})"
// Valid
"condition": "user_id = {sub}"

Check access.py:_SAFE_CONDITION_RE for the exact allowed grammar.


KeyError or AttributeError on module import

Section titled “KeyError or AttributeError on module import”

You’re likely importing a module that requires an environment variable before load_dotenv() runs.

# Wrong order
from kirak import create_kirak_app # reads env vars at import time
from dotenv import load_dotenv
load_dotenv()
# Correct
from dotenv import load_dotenv
load_dotenv()
from kirak import create_kirak_app

ModuleNotFoundError: No module named 'asyncmy' (or asyncpg)

Section titled “ModuleNotFoundError: No module named 'asyncmy' (or asyncpg)”

Install the driver for your database:

Terminal window
# MySQL
pip install "kirak[mysql]"
# PostgreSQL
pip install "kirak[postgres]"

Cause 1: Wrong secret key. The client has a token minted with a different KIRAK_AUTH_JWT_SECRET_KEY.

Cause 2: Algorithm mismatch. Token was signed with HS384 but auth.jwt_algorithm in kirak.json is HS256.

Cause 3: Token expired. Check access_token_expire_minutes / access_token_expire_hours in kirak.json’s auth section.

Cause 4: Token blacklisted. The token’s jti was added to the blacklist (logout was called). Request a new token via /auth/refresh-token.


Email verification is required by default. Either:

  • Send the user through the email verification flow (call /auth/verify-email?token=...).
  • Or disable for development by setting "email_verification_required": false in kirak.json’s auth section – this is a manifest setting, not an environment variable.

The user’s is_active column is false. Update it directly in the database or via an admin operation.


403 PERMISSION_DENIED on a route I should have access to

Section titled “403 PERMISSION_DENIED on a route I should have access to”
  1. Verify the JWT role matches an entry in the model’s access block.
  2. Check if there is a wildcard (*) fallback rule.
  3. Check that the model has an access block at all – without one, every operation is denied for every role (startup logs a warning naming the model). With one, unlisted operations are denied by default.
  4. Confirm the RLS condition resolves – if the JWT claim named in the condition is missing from the token, the condition evaluates to NULL which is treated as a deny.

429 RATE_LIMIT_EXCEEDED during development

Section titled “429 RATE_LIMIT_EXCEEDED during development”

Auth endpoints have hard-coded limits. Wait for the window to expire, or switch to a different IP/email for testing.

For per-model rate limits, reduce window_seconds during development or remove the rate_limit block temporarily.


database.pool_max x worker processes exceeds max_connections on the database server.

Solutions:

  1. Reduce database.pool_max.
  2. Reduce --workers on uvicorn.
  3. Add a connection pooler (PgBouncer for PostgreSQL, ProxySQL for MySQL) between Kirak and the database.

OperationalError: connection closed / MySQL server has gone away

Section titled “OperationalError: connection closed / MySQL server has gone away”

The connection was idle longer than the DB server’s wait_timeout and was killed.

Lower database.pool_recycle_seconds in kirak.json to recycle connections before the server closes them:

"database": { "pool_recycle_seconds": 3600 }

The MySQL default wait_timeout is 8 hours (28800 seconds); recycle before that.


Migration fails: Table 'kirak_migrations' doesn't exist

Section titled “Migration fails: Table 'kirak_migrations' doesn't exist”

Run kirak db init before kirak db migrate. The init command creates the tracking table.


kirak db status shows modified for a migration I haven’t changed

Section titled “kirak db status shows modified for a migration I haven’t changed”

The migration file on disk doesn’t match the checksum recorded when it was applied. This happens if you edited a migration file after applying it. Kirak will not re-apply it automatically.

Options:

  1. If the change was intentional and you’ve applied it manually, update the checksum:
    UPDATE kirak_migrations SET checksum = '<new-checksum>' WHERE name = 'migration_name';
  2. Create a new migration for the change instead of editing the existing file.

Bulk create fails: too many SQL parameters

Section titled “Bulk create fails: too many SQL parameters”

PostgreSQL has a hard limit of 32767 parameters per query. Bulk create calculates max_safe = 32767 // num_columns per batch and enforces it. If you see this error:

  • The capping logic may have been bypassed by passing a manual batch_size that’s too large.
  • Remove the manual batch_size and let Kirak calculate it automatically.

  1. Confirm the hook is registered inside on_kirak_ready – hooks registered after startup have no effect.
  2. Check the module’s log file (logs/{module}.log) for a timeout or exception log line.
  3. Verify the hook name matches exactly – after_create not after_created.

The hook exceeded hook_timeout_seconds (kirak.json, default 5.0 seconds). Kirak skips the hook and logs the timeout.

Either:

  • Speed up the hook’s operation.
  • Increase the timeout: "hook_timeout_seconds": 15 in kirak.json.
  • Move slow work to a background task (fire-and-forget).

Hooks are registered at startup. After changing hook code, restart the server. With --reload, file changes should auto-restart.


The upload_dir of the local provider instance (kirak.json, storage.providers.<name>.upload_dir) must be writable. Kirak creates it when the router is mounted.

The credentials for that provider instance are not set or lack the required S3 permissions (s3:PutObject, s3:DeleteObject, s3:GetObject). They are read from KIRAK_STORAGE_<INSTANCE>_ACCESS_KEY and KIRAK_STORAGE_<INSTANCE>_SECRET_KEY, e.g. KIRAK_STORAGE_MAIN_ACCESS_KEY for the instance named main.

The URL of an uploaded file answers 403 (S3-type storage)

Section titled “The URL of an uploaded file answers 403 (S3-type storage)”

Kirak returns a URL but does not make the file public. On r2, the S3 endpoint is never public: set public_url to the bucket’s r2.dev subdomain or custom domain. On aws, wasabi and cubbit, allow public reads on the bucket (or set "acl": "public-read" where the bucket accepts ACLs). On spaces, check that acl is not private. On ovh, add a bucket policy or set "acl": "public-read". On b2, make the bucket public in Backblaze. For files that should stay private, use get_url with expires. See Storage.

Google Cloud Storage: get_url with expires fails, or check() warns about signBlob

Section titled “Google Cloud Storage: get_url with expires fails, or check() warns about signBlob”

Under Application Default Credentials (no credentials_json) there is no private key to sign URLs with. Set signing_service_account on the gcs instance to a service account email, and give the app’s identity iam.serviceAccounts.signBlob on it (the Service Account Token Creator role). check() signs one URL to prove it works and reports a warning (permission_denied) when it does not.

Azure Blob Storage: file URLs answer 404 or 409, or check() warns about the Storage Blob Delegator role

Section titled “Azure Blob Storage: file URLs answer 404 or 409, or check() warns about the Storage Blob Delegator role”

A blob URL answers PublicAccessNotPermitted (409) or ResourceNotFound (404) when the container does not allow anonymous access; new storage accounts disallow it at account level. Allow blob-level public access, set public_url to Front Door or a CDN, or use get_url with expires. Under an Azure identity (no connection string or account key), signed URLs need a user delegation key: give the identity the Storage Blob Delegator role. check() requests one and reports a warning (permission_denied) when it is refused.

The file MIME type is not in the allowed list, or the file size exceeds image_max_size (kirak.json, storage). Check the error message for which constraint was violated.


TASK_NOT_REGISTERED (400): Task ‘my_task’ is not registered

Section titled “TASK_NOT_REGISTERED (400): Task ‘my_task’ is not registered”

The task name passed to enqueue() doesn’t match the name registered with @scheduler.task("my_task"). Names are case-sensitive and must match exactly.

enqueue() was called before the scheduler connected its backends. This usually means calling enqueue() at module import time rather than inside a request handler or a hook.

There is no separate kirak worker CLI command – the scheduler worker starts embedded inside the application process automatically when "scheduler" is in modules, so if jobs aren’t processing, the app process itself is the thing to check.

  1. Confirm the app process has "scheduler" in its enabled modules and actually started (check startup logs for scheduler initialization).
  2. Check the application log for worker errors.
  3. Verify scheduler.default_provider and the provider type in kirak.json match what you expect – e.g. a redis provider named redis with KIRAK_SCHEDULER_REDIS_URL unset will fail to connect.
  4. For the database backend, check the scheduler_jobs table for rows where status = 'pending' and run_at <= now().

The app does not start: “Invalid MCP server files”

Section titled “The app does not start: “Invalid MCP server files””

Startup lists every problem with the files in mcp/, with the file and the key. The usual ones: a tool ref that names no model or module operation (unknown_tool_ref, unknown_tool_operation), a module that kirak.json does not enable (tool_module_not_enabled), an operation that cannot be a tool such as a webhook (tool_not_callable), a JSON key written twice, or two files with the same name. See MCP Servers.

401: no credential, or one that is invalid or expired – send Authorization: Bearer <JWT or API key> or X-API-Key. 403: the caller’s role is below the server’s access, or the account is deactivated.

A tool answers CONFIRMATION_UNAVAILABLE or CONFIRMATION_DECLINED

Section titled “A tool answers CONFIRMATION_UNAVAILABLE or CONFIRMATION_DECLINED”

The tool has requires_confirmation: true. CONFIRMATION_UNAVAILABLE: the client is on an MCP protocol older than 2026-07-28 and cannot show the confirmation form, so the tool never runs for it – update the client. CONFIRMATION_DECLINED: the person did not approve the call. See Confirmation.


Every module – Auth, CrudEngine, and all optional modules – shares one rotating log file, {log_path}/kirak.log (default logs/kirak.log; set the directory with log_path in kirak.json). There are no separate per-module log files: independent handlers rotating the same file at different moments could race and corrupt it, so all modules log to the one shared handler. Each log line includes the emitting module’s name, so you can still tell modules apart.

Set the log_level field in kirak.json (not an environment variable) for verbose output including SQL queries.

During early development, remove the access block from a model entirely. Without an access block, all operations are allowed without authentication.

Never deploy to production without access blocks.

from fastapi import FastAPI
app: FastAPI = ... # your kirak app
for route in app.routes:
print(route.path, getattr(route, "methods", ""))

Or read http://localhost:8000/docs (or /openapi.json) while the server is running: both list every mounted route.