Skip to content

Authentication

Kirak ships with a complete authentication system – registration, login, password management, email verification, OTP, MFA, social login, and JWT lifecycle – with zero additional code.

Response shape. Auth endpoints use the canonical response envelope: success is {"statusCode": 200, "status": "success", "message": str, "data": ...}, failure is {"statusCode": <4xx/5xx>, "status": "error", "error": "<CODE>", "message": str, "data": null}. The HTTP status line always equals statusCode. Failure error codes are the specific auth codes (INVALID_CREDENTIALS, EMAIL_NOT_VERIFIED, MFA_REQUIRED, TOKEN_EXPIRED, …) – see the auth machine codes table.


A model operation whose access rule includes {"role": "*"} is reachable with no credential. An unauthenticated request resolves to the guest principal {"role": "guest"}, and {"role": "*"} matches any role including guest. This is the anonymous pattern the client SDK uses: unauthenticated calls for public data, a user JWT for everything else.

{
"access": {
"fetch": [ { "role": "*" } ],
"create": [ { "role": "user" } ]
}
}
  • A present but invalid token is 401 (TOKEN_EXPIRED / INVALID_TOKEN). Only an absent credential means guest – a stale token is not silently downgraded.
  • The public auth routes (/auth/login, /auth/register, /auth/request-reset-password, /auth/reset-password, /auth/refresh-token, /auth/verify-email) never read the Authorization header, so a client that still sends an old or logged-out token can sign in again. Only the routes that act on the signed-in user (/auth/me, change-password, the MFA and API key routes) require it.
  • Guests are rate-limited per client IP (rate_limit.per: "ip", the default – the key never references a principal). per: "user" buckets guests as anon:<ip>.
  • Rate limiters fail open: if Redis / the DB limiter table is unavailable, the request is allowed. Do not rely on rate limiting as a hard security boundary.
  • There is no browser-safe “publishable key” today. A user-bound API key (kk_) resolves to a real user with a 365-day default expiry and must never ship in a browser or mobile bundle.

All endpoints are mounted at /auth by default. auth_prefix replaces that path, the same way module_prefixes does for other modules:

create_kirak_app(models_path="...", auth_prefix="/api/auth") # -> /api/auth/login, /api/auth/me, ...

The links Kirak builds itself follow auth_prefix: the verification and password reset links in emails, the default social redirect_uri ({base_url}{auth_prefix}/{provider}/callback), and the built-in reset page. The route tables below use the default /auth.

Kirak client SDK: the SDK calls the default auth routes at /auth/*. Keep the default auth_prefix if your app uses the SDK; with a custom one, its auth calls do not reach your routes.

Method Path Description
POST /auth/register Create a new user account
POST /auth/login Authenticate and receive tokens
POST /auth/logout Blacklist the current access token
POST /auth/change-password Change password (requires current JWT); blacklists the caller’s current access token, forcing re-login with the new password
GET /auth/me Fetch the current authenticated user
POST /auth/get-current-user Same as /me but as a POST (useful for some clients)
POST /auth/generate-verification-link Admin/system only – generate a verification link for any user
Method Path Description
GET /auth/verify-email?token=... Verify email using the signed link sent to the user’s inbox
Method Path Description
POST /auth/request-reset-password Send password reset email with a signed link
POST /auth/reset-password Apply new password using the reset token
GET /auth/forgot-password HTML page rendered for the password reset flow
Method Path Description
POST /auth/refresh-token Exchange a valid refresh token for a new access token
Method Path Description
POST /auth/request-otp Send a one-time password to a phone number
POST /auth/verify-otp Verify OTP and receive tokens (login/register)
Method Path Description
POST /auth/setup-mfa Generate a TOTP secret + QR code for authenticator app setup
POST /auth/verify-mfa Verify a TOTP code to complete MFA setup or login
POST /auth/disable-mfa Disable MFA for the current user
Method Path Description
GET /auth/{provider}/login Redirect to OAuth provider’s authorization URL
GET /auth/{provider}/callback Handle provider callback (browser-based flows)
POST /auth/apple/callback Handle Apple’s form POST callback
POST /auth/{provider}/mobile Sign in with the token a native social SDK returned; the provider checks it
POST /auth/verify-web-auth Exchange a short-lived web auth token for real credentials
Method Path Description
POST /auth/api-keys/create Create an API key for the authenticated user
GET /auth/api-keys/list List all API keys for the authenticated user
DELETE /auth/api-keys/revoke Revoke an API key (id or key_prefix in the request body)

Rate limit admin endpoints live under /admin, not /auth – rate limiting is core infrastructure, not an auth concern.

Method Path Description
GET /admin/rate-limits List current rate limit counters (admin/system only)
GET /admin/rate-limits/{key} Get one rate limit counter (admin/system only)
DELETE /admin/rate-limits/{key} Reset one rate limit counter (admin/system only)

See Rate Limiting for the key format and query parameters.


POST /auth/register

{
"email": "alice@example.com",
"password": "Secret!Pass123",
"first_name": "Alice",
"last_name": "Smith",
"role": "user"
}
  • Password is bcrypt-hashed before INSERT. The plaintext is never stored.
  • Password must be at least 12 characters. With auth.password_complexity on (the default) it also needs an uppercase letter, a lowercase letter, a digit, and one of !@#$%^&*(),.?":{}|<> – other symbols such as -, _ or + do not count as special characters. A password that fails returns 400 WEAK_PASSWORD. The same rules apply to change-password and reset-password.
  • role is always "user" for self-registration – it is never caller-controlled, regardless of what’s sent in the request body. There is no registration path that grants an elevated role; elevate a user’s role directly in the database or through a custom admin operation.
  • base_url must be set in kirak.json – it’s required to build the verification link. If KIRAK_AUTH_VERIFICATION_KEY is also set, a verification email is sent automatically.
  • After registration, hooks fire: before_register (with payload) -> INSERT -> after_register (with result).

POST /auth/login

{
"email": "alice@example.com",
"password": "Secret!Pass123"
}

Rate limits: two counters, per IP and per email, each at the global rate_limit defaults (see Rate Limiting).

If the user has MFA enabled, send the authenticator code as mfa_code in the same body; without it login answers 403 MFA_REQUIRED. See MFA & OTP.

Response:

{
"statusCode": 200,
"status": "success",
"message": "Login successful",
"data": {
"user": {
"user_id": 1,
"email": "alice@example.com",
"first_name": "Alice",
"last_name": "Smith",
"phone_number": null,
"role": "user",
"is_verified": true,
"created_at": "2026-08-01T09:00:00+00:00",
"last_login_at": "2026-08-27T10:11:12+00:00"
},
"accessToken": "eyJhbGc...",
"refreshToken": "eyJhbGc...",
"expiresIn": 3600
}
}

Token keys are camelCase (accessToken, refreshToken); expiresIn is the access-token lifetime in seconds. The user’s primary key is returned as user_id.

If cookie_name is set in kirak.json’s auth section (a manifest setting, not an environment variable), the access token is also written as a cookie: HttpOnly, Secure, SameSite=Lax, Path=/, domain auth.cookie_domain, and a max age of the access token lifetime. It is set by login, refresh-token, verify-otp and the social callback and mobile routes, and deleted by logout. SameSite=Lax means browsers send it only on same-site requests (subdomains of one site count); a frontend on a different site must use the Authorization header.


Token Lifetime Used for
Access token access_token_expire_minutes, else access_token_expire_hours, in kirak.json’s auth section (default 1 hour) Authenticating API requests via Authorization: Bearer header
Refresh token refresh_token_expire_days in kirak.json’s auth section (default 14 days) Obtaining a new access token

Both lifetimes are manifest settings (kirak.json), not environment variables.

Requests are authenticated from the token, not the database. The caller’s user_id, role and email come from the signed access token’s claims; no users row is read on a request (only the blacklist is checked, see Logout). The credential is checked once per HTTP request, so hooks and nested kirak.* calls reuse it. GET /auth/me is the exception: it reads the users row to return the current profile.

So a change to a user’s role, or deactivating or deleting the user, takes effect when their access token is next refreshed – POST /auth/refresh-token re-reads the user, rejects inactive or deleted users, and issues the new token with the current role. Until then the old token keeps its claims, for at most the access token lifetime. Choose a short lifetime ("access_token_expire_minutes": 5) when changes must apply quickly.

Access token payload:

{
"sub": "1",
"role": "user",
"email": "alice@example.com",
"jti": "unique-token-id",
"type": "access",
"iat": 1724184000,
"exp": 1724187600
}

Refresh token payload:

{
"sub": "1",
"jti": "unique-token-id",
"type": "refresh",
"iat": 1724184000,
"exp": 1725393600
}

The jti guarantees every issued refresh token is unique even when two are minted in the same second (for example login immediately followed by a refresh).

Sending the access token:

Authorization: Bearer eyJhbGc...

POST /auth/refresh-token

{ "refreshToken": "eyJhbGc..." }

Response:

{
"statusCode": 200,
"status": "success",
"message": "Tokens refreshed successfully",
"data": {
"accessToken": "eyJhbGc...",
"refreshToken": "eyJhbGc...",
"expiresIn": 3600
}
}

Refresh tokens are rotated. Each call issues a new access token and a new refresh token, then deletes the old refresh token from auth_tokens. The rotation runs in a single transaction – the new row is inserted before the old one is removed, so a mid-flight failure rolls back and the client can safely retry with the same token. The old refresh token is invalid after a successful call; store the returned refreshToken for the next refresh.

The presented refresh token must still exist in auth_tokens (it is looked up by hash) and not be past its own expiry. A refresh token that has already been rotated away, revoked, or expired returns 401.

If cookie_name is set in kirak.json’s auth section, the new access token is also written back as the auth cookie.


POST /auth/logout

Authorization: Bearer <accessToken>
{ "token": "<refreshToken>", "logout_type": "single" }

The body token is the refresh token from login (or the last refresh). It is looked up in auth_tokens; an access token there returns 404 Token not found or already invalidated and nothing is logged out. The access token in the Authorization header is the one that gets blacklisted. The header is optional: without it (or with an expired or foreign token) logout still deletes the refresh token and succeeds, but no access token is blacklisted.

logout_type is optional (default "single"):

Value Effect
single Delete the presented refresh token.
all Delete every refresh token for the user (logs out all devices).
others Delete every refresh token for the user except the one presented (logs out all other devices).

Logout deletes refresh tokens only: the user’s API keys stay valid with every logout_type (revoke them with DELETE /auth/api-keys/revoke).

When the header carries a valid access token, it is added to the blacklist by its jti; later requests with it receive 401. Other access tokens already issued to the user are not blacklisted and stay valid until they expire (the access token lifetime), even with all.

  • Without Redis – blacklist entries stored in auth_token_blacklist table. Expired entries are automatically skipped during checks (the query filters expires_at > NOW()), but rows are not purged automatically – run a periodic job to delete expired rows: DELETE FROM auth_token_blacklist WHERE expires_at < NOW().
  • With Redis (KIRAK_REDIS_URL set) – blacklist stored as Redis keys with per-token TTL. Entries auto-expire with no manual cleanup needed.

The blacklist fails closed: a logged-out token is never accepted because the blacklist is down.

  • Logout blacklists the access token before deleting any refresh token. If the blacklist cannot be written, logout returns 503 AUTH_UNAVAILABLE and deletes nothing, so the client can simply retry.
  • A request whose token cannot be checked against the blacklist (database or Redis unreachable) gets 503 AUTH_UNAVAILABLE, not access. Retry the request; the token itself is not invalid.
  • Change-password blacklists the caller’s token after the password is changed, on a best-effort basis: if that write fails the password change still succeeds, and the token lasts until it expires.

POST /auth/change-password (requires Bearer token)

{
"current_password": "Secret!Pass123",
"new_password": "Secret!Pass456",
"confirm_password": "Secret!Pass456"
}

All three fields are required. A wrong current_password returns 401 INVALID_CREDENTIALS; a mismatched confirmation returns 400 VALIDATION_ERROR. On success the caller’s access token is blacklisted and all of the user’s refresh tokens are revoked, so every device must log in again with the new password. API keys are not tied to the password and stay valid; revoke them separately if they may be compromised.


  1. User calls POST /auth/request-reset-password with their email.
  2. Kirak generates a Fernet-signed token and emails a link: auth.forgot_password_page_url?token=...
  3. The frontend renders the reset form at that URL.
  4. User submits new password to POST /auth/reset-password with { "token": "...", "new_password": "...", "confirm_password": "..." } (all three required).
  5. Kirak verifies the token, checks expiry, and updates the password.

A reset token is valid for 1 hour and can be used once; a second use returns 400 INVALID_RESET_TOKEN. A successful reset deletes every auth_tokens row of the user – refresh tokens and API keys alike – so all devices must log in again. Unlike change-password, a reset assumes the account may have been taken over, so API keys go too. Access tokens already issued are not blacklisted and stay valid until they expire.

Rate limit: one counter per email, at the global rate_limit defaults (see Rate Limiting).

POST /auth/request-reset-password never reveals whether an address is registered: it answers If your email is registered, you will receive reset instructions for an unknown address, a successful send, and any failure while generating the link or sending the email. Failures after the address lookup (which only happen for registered addresses) are written to the server log at error level instead of being returned, so watch the log for [RESET_REQUEST] entries if users report missing emails. A missing email is still a 400.


After registration (when KIRAK_AUTH_VERIFICATION_KEY is set), Kirak sends a verification email with a Fernet-signed link. The user clicks GET /auth/verify-email?token=..., the token is verified, and the users.is_verified column is set. The link is valid for 24 hours; opening it again reports that the email is already verified.

If no email provider is configured, registration still succeeds and returns "verification_email_sent": false. With auth.email_verification_required on (the default, a kirak.json setting), the user cannot log in (403 EMAIL_NOT_VERIFIED) until they are verified – use the admin flow below, or set users.is_verified directly. Set it to false to let unverified users log in (for example in development).

To manually generate a verification link for any user (admin flow):

POST /auth/generate-verification-link (requires admin, system, or superadmin role)

{ "user_id": 42, "email": "alice@example.com", "type": "email_verification" }

All three fields are required, and user_id and email must belong to the same user. type is email_verification or password_reset; a password_reset link points at auth.forgot_password_page_url and is valid for 1 hour. The link is returned as data.secure_link:

{
"statusCode": 200,
"status": "success",
"message": "Secure link generated successfully",
"data": {
"secure_link": "https://api.example.com/auth/verify-email?token=...",
"type": "email_verification"
}
}

Every limited auth route uses the global rate_limit.default_max_requests / default_window_seconds from kirak.json (100 requests per 60 seconds when unset). There are no per-route numbers; what differs is the counter a request is counted against:

Route Counted per
POST /auth/login IP, and email (two counters)
POST /auth/register IP
GET /auth/verify-email IP
POST /auth/request-reset-password email
POST /auth/request-otp phone number
POST /auth/verify-otp phone number
POST /auth/{provider}/mobile IP
GET /auth/{provider}/callback, POST /auth/apple/callback IP (one shared counter)
POST /auth/setup-mfa IP
POST /auth/verify-mfa IP
POST /auth/disable-mfa IP

The other auth routes (refresh-token, reset-password, logout, change-password, me, verify-web-auth and the API key routes) have no rate limit. To make the auth limits stricter, lower the global defaults.

All limits use sliding windows (Redis) or fixed windows (database). Exceeded limits return HTTP 429 with Retry-After header. IP resolution respects trusted_proxy_ips (kirak.json), and the global rate_limit.enabled flag disables these limits too, not just per-model CRUD limits. See Rate Limiting for the full picture, including the /admin/rate-limits endpoints for inspecting and resetting individual counters.


See Hooks & Events for the full list. Key patterns:

@kirak.auth.hook("after_login")
async def add_tenant_to_response(result):
if result.get("status") == "success":
user_id = result["data"]["user"]["user_id"]
# fetch tenant_id from DB...
result["data"]["user"]["tenant_id"] = tenant_id
return result

This changes only the JSON the client receives. The tokens are already signed when after_login runs, so a hook cannot add claims to them: the access token always carries exactly sub, email, role, jti, type, iat and exp.

@kirak.auth.hook("after_register")
async def send_welcome(result):
if result.get("status") == "success":
email = result["data"]["user"]["email"]
try:
await kirak.notifications.send_email({
"to": email,
"template_name": "welcome", # no .py -- the operation appends the extension itself
})
except KirakException as e:
# Log error but don't block registration
print(f"Failed to send welcome email: {e.message}")
return result

API keys let server-side clients authenticate without a user session. They are sent in the X-API-Key request header and resolve to the user who created them.

POST /auth/api-keys/create (requires Bearer token)

{
"name": "Production worker",
"expires_at": "2027-12-31T23:59:59Z"
}

Both fields are optional. name is a human-readable label for the key list. expires_at is an ISO-8601 datetime (Z, an offset, or none for UTC); it is stored in UTC. Any other value returns 400 VALIDATION_ERROR.

Default expiry: Keys expire 365 days from creation when expires_at is omitted. This default exists so credentials are bounded by design. Applications that need a different lifetime must pass expires_at explicitly on every create call – there is no per-application configuration for the default. Passing expires_at: null is not supported; omitting the field always applies the 365-day default.

{
"statusCode": 200,
"status": "success",
"message": "API key created. Store the key securely -- it will not be shown again.",
"data": {
"id": 42,
"api_key": "kk_...",
"key_prefix": "kk_a1b2c3d",
"name": "Production worker",
"expires_at": "2027-08-29T12:00:00+00:00",
"created_at": "2026-08-29T12:00:00+00:00"
}
}

The raw key is returned exactly once. It is not stored and cannot be retrieved again. Store it immediately in your secret manager.

Include the key in the X-API-Key header on model requests such as /posts/fetch (the auth session routes such as /auth/me accept only a Bearer token):

X-API-Key: kk_...

Bearer token auth takes priority if both headers are present. An expired or revoked key returns HTTP 401 with "error": "INVALID_API_KEY".

GET /auth/api-keys/list (requires Bearer token)

Returns all non-revoked keys for the current user. The raw key value is never included; only the prefix, name, and metadata are returned.

DELETE /auth/api-keys/revoke (requires Bearer token)

{ "id": 42 }

Identify the key by id or by key_prefix (as returned at creation). Revokes the key immediately. In-flight requests that already passed the cache check (60-second Redis TTL) may succeed for up to 60 seconds after revocation.

Expired keys are rejected at the database lookup step, before any user data is fetched. A key that expires while cached in Redis will still be accepted for up to 60 seconds after expiry. For security-critical revocations, flush the Redis cache or rely on the hard database check by disabling Redis caching.

Users with role admin, system or superadmin may create keys for other users by including user_id in the request body (the same roles may list and revoke other users’ keys):

{
"user_id": 99,
"name": "Service account",
"expires_at": "2027-01-01T00:00:00Z"
}

Non-admin users may only create keys for themselves.

Only an HMAC-SHA256 hash of each key is stored, keyed by KIRAK_AUTH_API_KEY_SECRET. When that variable is not set, the hash is keyed by KIRAK_AUTH_JWT_SECRET_KEY, and rotating the JWT secret invalidates every API key. Set KIRAK_AUTH_API_KEY_SECRET in production (Kirak logs a warning at startup while it is unset). Keys created before you set it keep working (they are still matched with the JWT secret) until the JWT secret changes; recreate them to move them onto the new secret.

Logout (any logout_type) and change-password leave API keys valid. Only an explicit revoke, expiry, or a password reset (which deletes every auth_tokens row of the user) ends a key.


# From a custom route:
auth = request.app.state.kirak.auth
# Login programmatically:
result = await auth.login({"email": "...", "password": "..."})
# Get current user from request:
result = await auth.get_current_user({"request": request})
user = result.get("data", {})
# Verify a JWT token:
from kirak.auth.utils.jwt import decode_token
payload = decode_token(token)

The auth module manages these tables automatically (all internal: true; kirak_rate_limits belongs to Kirak core and is listed because the auth rate limits use it):

Table Purpose
users User accounts: email, password (bcrypt hash), role, first_name, last_name, is_verified, is_active, phone_number, etc.
auth_tokens Refresh tokens, API keys and one-time password reset tokens (hashed, by token_type)
auth_social Social provider -> user mappings
auth_mfa TOTP secrets per user
auth_otp OTP codes with expiry
auth_token_blacklist Revoked JTI values
kirak_rate_limits Per-key rate limit counters (DB backend)