Skip to content

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 enables passwordless login via SMS. There is no separate sign-up step – the first successful OTP verification creates the user automatically.

POST /auth/request-otp

{
"phone_number": "+14155552671"
}
  • Kirak generates a 6-digit OTP and stores it (in the auth_otp table, or Redis if KIRAK_REDIS_URL is 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".

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.

Backend Storage
No Redis auth_otp table (OTP + expiry column)
Redis Per-phone key with per-OTP TTL – auto-expires, no cleanup needed

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.

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.

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.

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.


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_at

totp_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