MFA & OTP
Kirak provides two distinct phone/device authentication mechanisms:
- OTP – phone number-based, passwordless login. A one-time code is sent via SMS; the user submits it to receive JWT tokens.
- MFA (TOTP) – two-factor authentication using an authenticator app (Google Authenticator, Authy). Set up once, required on subsequent password logins.
MFA requires the pyotp package: pip install "kirak[mfa]".
OTP (Phone Authentication)
Section titled “OTP (Phone Authentication)”OTP enables passwordless login via SMS. There is no separate sign-up step – the first successful OTP verification creates the user automatically.
Step 1 – Request OTP
Section titled “Step 1 – Request OTP”POST /auth/request-otp
{ "phone_number": "+14155552671"}- Kirak generates a 6-digit OTP and stores it (in the
auth_otptable, or Redis ifKIRAK_REDIS_URLis set) with a short TTL. - Sends the OTP via SMS using the configured SMS provider.
- Rate limit: 3 requests per phone number per hour.
Any role included in the request is not persisted – a new account created via OTP is always given role "user".
Step 2 – Verify OTP
Section titled “Step 2 – Verify OTP”POST /auth/verify-otp
{ "phone_number": "+14155552671", "otp": "482913"}- If the OTP matches and has not expired, Kirak looks up the user by phone number.
- If no user exists, a new account is created with role
"user". - Returns standard auth tokens.
- Rate limit: 5 verify attempts per phone number per hour.
- OTP is invalidated immediately after successful verification.
Response:
{ "statusCode": 200, "status": "success", "message": "Phone number verified successfully", "data": { "user": { "user_id": 42, "phone_number": "+14155552671", "role": "user" }, "accessToken": "eyJhbGc...", "refreshToken": "eyJhbGc...", "expiresIn": 3600 }}If cookie_name is set in kirak.json’s auth section, the access token is also written as an HttpOnly cookie.
OTP Storage
Section titled “OTP Storage”| Backend | Storage |
|---|---|
| No Redis | auth_otp table (OTP + expiry column) |
| Redis | Per-phone key with per-OTP TTL – auto-expires, no cleanup needed |
MFA (TOTP – Authenticator App)
Section titled “MFA (TOTP – Authenticator App)”MFA is a second factor added on top of email/password login. Once enabled, a login attempt without a valid TOTP (or backup) code is rejected.
POST /auth/setup-mfa (requires valid JWT)
{ "statusCode": 200, "status": "success", "message": "Scan the QR code with your authenticator app. Verify with your first code to activate MFA.", "data": { "provisioning_uri": "otpauth://totp/Kirak:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Kirak", "backup_codes": ["A1B2-C3D4E5", "..."] }}There is no server-rendered QR image – render one client-side from provisioning_uri (most authenticator-app QR libraries take a URI directly). backup_codes are shown exactly once; store them securely. MFA is created but stays inactive until you complete setup with verify-mfa.
Complete Setup
Section titled “Complete Setup”POST /auth/verify-mfa (called after setup)
{ "code": "482913", "action": "activate"}action defaults to "activate" when omitted. Verifying the first TOTP code (or a backup code) activates MFA for the account.
Login with MFA
Section titled “Login with MFA”Unlike a separate step, MFA is checked inside the same /auth/login call. Include mfa_code in the login request from the start if you already know MFA is required for that account:
{ "email": "alice@example.com", "password": "Secret!Pass123", "mfa_code": "482913"}If MFA is enabled and mfa_code is missing or wrong, login fails with 403 MFA_REQUIRED (or 401 INVALID_MFA_CODE for a wrong code) – no tokens are issued at that point. The client’s job is to catch MFA_REQUIRED, prompt for the authenticator code, and retry the same /auth/login call with mfa_code added:
{ "email": "alice@example.com", "password": "Secret!Pass123", "mfa_code": "482913"}A backup code works in place of mfa_code here too; using one consumes it.
verify-mfa also accepts action: "login" for step-up verification against an already-authenticated session, separate from the plain login flow above.
Disable MFA
Section titled “Disable MFA”POST /auth/disable-mfa (requires valid JWT)
{ "code": "482913"}code (a TOTP or backup code) is the only required field – confirms the request is coming from someone who still controls the authenticator, then removes the auth_mfa record.
MFA Table
Section titled “MFA Table”auth_mfa+-- user_id -> FK to users.id (unique)+-- totp_secret -> TOTP secret, encrypted at rest (see below)+-- backup_codes -> JSON array of hashed backup codes+-- is_enabled -> bool, set to true after first verify-mfa+-- created_attotp_secret is encrypted with AES-256-GCM before storage, using the same KIRAK_AUTH_ENCRYPTION_KEY as social login token data (see Configuration), with the user’s id bound in as authenticated data so a ciphertext copied to a different user’s row fails to decrypt.
@kirak.auth.hook("after_verify_otp")async def after_otp_login(result): if result.get("status") == "success": user = result["data"]["user"] # log phone login, enrich response... return result
@kirak.auth.hook("after_verify_mfa")async def after_mfa_verified(result): if result.get("status") == "success": user_id = result["data"].get("user", {}).get("id") if user_id: await kirak.create("audit_logs", {"data": { "user_id": user_id, "action": "mfa_verified", }}) return result