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 equalsstatusCode. Failureerrorcodes are the specific auth codes (INVALID_CREDENTIALS,EMAIL_NOT_VERIFIED,MFA_REQUIRED,TOKEN_EXPIRED, …) – see the auth machine codes table.
Anonymous / public access
Section titled “Anonymous / public access”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 theAuthorizationheader, 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 asanon:<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.
Auth Routes
Section titled “Auth Routes”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 defaultauth_prefixif your app uses the SDK; with a custom one, its auth calls do not reach your routes.
Account Management
Section titled “Account Management”| 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 |
Email Verification
Section titled “Email Verification”| Method | Path | Description |
|---|---|---|
| GET | /auth/verify-email?token=... |
Verify email using the signed link sent to the user’s inbox |
Password Reset
Section titled “Password Reset”| 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 |
Token Management
Section titled “Token Management”| Method | Path | Description |
|---|---|---|
| POST | /auth/refresh-token |
Exchange a valid refresh token for a new access token |
OTP (Phone Authentication)
Section titled “OTP (Phone Authentication)”| 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) |
MFA (TOTP)
Section titled “MFA (TOTP)”| 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 |
Social / OAuth
Section titled “Social / OAuth”| 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 |
API Keys
Section titled “API Keys”| 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
Section titled “Rate Limit Admin”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.
Registration
Section titled “Registration”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_complexityon (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 400WEAK_PASSWORD. The same rules apply to change-password and reset-password. roleis 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_urlmust be set inkirak.json– it’s required to build the verification link. IfKIRAK_AUTH_VERIFICATION_KEYis 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.
Auth cookie
Section titled “Auth cookie”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.
JWT Tokens
Section titled “JWT Tokens”| 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...Token Refresh
Section titled “Token Refresh”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.
Logout and Token Blacklisting
Section titled “Logout and Token Blacklisting”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_blacklisttable. Expired entries are automatically skipped during checks (the query filtersexpires_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_URLset) – 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_UNAVAILABLEand 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.
Change Password
Section titled “Change Password”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.
Password Reset Flow
Section titled “Password Reset Flow”- User calls
POST /auth/request-reset-passwordwith their email. - Kirak generates a Fernet-signed token and emails a link:
auth.forgot_password_page_url?token=... - The frontend renders the reset form at that URL.
- User submits new password to
POST /auth/reset-passwordwith{ "token": "...", "new_password": "...", "confirm_password": "..." }(all three required). - 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.
Email Verification
Section titled “Email Verification”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" }}Rate Limiting
Section titled “Rate Limiting”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 |
|
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.
Auth Hooks
Section titled “Auth Hooks”See Hooks & Events for the full list. Key patterns:
Add fields to the login response
Section titled “Add fields to the login response”@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 resultThis 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.
Send welcome email on register
Section titled “Send welcome email on register”@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 resultAPI Keys
Section titled “API Keys”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.
Creating a key
Section titled “Creating a key”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.
Using a key
Section titled “Using a key”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".
Listing keys
Section titled “Listing keys”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.
Revoking a key
Section titled “Revoking a key”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.
Expiry enforcement
Section titled “Expiry enforcement”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.
Admin key creation
Section titled “Admin key creation”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.
Key storage and secret rotation
Section titled “Key storage and secret rotation”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.
Accessing the Auth Module Directly
Section titled “Accessing the Auth Module Directly”# 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_tokenpayload = decode_token(token)Auth Tables
Section titled “Auth Tables”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) |