Skip to content

OTP — Email & SMS

One-time passwords are Craft Easy's default passwordless login. A six-digit code is delivered over email or SMS, the user submits it, and the API returns a signed JWT. The full flow is implemented by OTPService (craft_easy.core.auth.otp) and the /auth/login/* routes.

The four-step flow

1. POST /auth/login/email     →  code delivered to inbox
2. POST /auth/login/verify    →  returns JWT (or pre-auth token if 2FA is on)
3. X-API-Key: <token>         →  authenticated calls
4. POST /auth/refresh         →  extend the session before it expires

All four steps are also usable over SMS by calling POST /auth/login/sms in step 1.

Step 1 — Request a code

import httpx

BASE = "http://localhost:8000"

resp = httpx.post(f"{BASE}/auth/login/email", json={
    "email": "user@example.com",
    "application": "my-app",
})
resp.raise_for_status()
print(resp.json())
# {"message": "Verification code sent", "method": "email"}
curl -X POST 'http://localhost:8000/auth/login/email' \
  -H 'Content-Type: application/json' \
  -d '{"email": "user@example.com", "application": "my-app"}'

The application field is a free-form identifier (for example portal, admin, warden). It is written to the token's origin_app claim and surfaces in session lists and audit logs so operators can tell where a login came from.

SMS works identically:

httpx.post(f"{BASE}/auth/login/sms", json={
    "phone": "+46701234567",
    "application": "my-app",
})

Step 2 — Verify and receive a token

resp = httpx.post(f"{BASE}/auth/login/verify", json={
    "target": "user@example.com",
    "code": "123456",
    "application": "my-app",
})
resp.raise_for_status()
data = resp.json()

token = data["token"]
if data["requires_2fa"]:
    # User has TOTP/WebAuthn enabled — see auth/totp.md
    ...

Response shape:

{
  "token": "eyJhbGciOiJFUzUxMiJ9...",
  "token_type": "user",
  "expires": "2026-04-05T12:30:00Z",
  "requires_2fa": false
}

When requires_2fa is true, the returned token is a pre-auth token — it cannot access any other endpoint until the caller completes POST /auth/2fa/verify.

Step 3 — Use the token

client = httpx.Client(
    base_url=BASE,
    headers={"X-API-Key": token},
)
products = client.get("/products/").json()
curl 'http://localhost:8000/products/' \
  -H "X-API-Key: ${TOKEN}"

Step 4 — Refresh before expiry

User tokens last JWT_USER_EXPIRY_MINUTES (default 30 minutes) and can be refreshed up to JWT_REFRESH_MAX_LIFETIME_MINUTES (default 30 days) from the original login.

resp = client.post("/auth/refresh")
new_token = resp.json()["token"]
client.headers["X-API-Key"] = new_token

How it works under the hood

OTPService exposes four primitives:

from craft_easy.core.auth import OTPService

svc = OTPService(code_length=6, expiry_minutes=10, max_attempts=5, max_sends=3)

code = svc.generate_code()             # "437192"
code_hash = OTPService.hash_code(code) # SHA-256 — stored in DB
OTPService.verify_code("437192", code_hash)  # True (constant-time)
svc.is_expired(created_at)             # bool
svc.can_send_again(send_count)         # bool
svc.can_attempt(attempt_count)         # bool

Codes are never stored in clear. Only a SHA-256 hash is written to the authentication_codes collection. verify_code uses hmac.compare_digest for constant-time comparison to avoid timing attacks.

Configuration

All settings live on CraftEasySettings and can be overridden per project:

Setting Default Purpose
OTP_CODE_LENGTH 6 Number of digits in generated codes.
OTP_EXPIRY_MINUTES 10 How long a code is valid after delivery.
OTP_MAX_ATTEMPTS 5 Verification attempts allowed before the code is invalidated.
OTP_MAX_SENDS 3 Re-sends allowed per target per code.
OTP_AUTO_CREATE_USER False When True, unknown emails automatically create a user on first verification.

Abuse-related limits (OTP_RECIPIENT_MAX_PER_HOUR, MAX_DAILY_SMS, …) are documented in Abuse Protection.

Delivery

OTPService is delivery-agnostic — it only generates and verifies codes. The actual email/SMS sending is handled by NotificationService in craft_easy.core.notifications. The default notification service supports SMTP for email and Twilio for SMS; both are configured through the standard notification settings.

Error responses

Status Body Cause
400 {"detail": "Disposable email addresses are not allowed"} BLOCK_DISPOSABLE_EMAILS triggered.
429 {"detail": "Too many verification codes sent. Try again in an hour."} Per-recipient hourly limit reached.
429 {"detail": "Daily SMS limit reached. Service temporarily unavailable."} Global daily cap hit.
400 {"detail": "Invalid or expired code"} Wrong code or code past its expiry.
400 {"detail": "Too many attempts"} OTP_MAX_ATTEMPTS exceeded.

Each of these is raised from craft_easy.core.auth.abuse or the /auth/login/verify route handler. See Abuse Protection for the full list.

Endpoints reference

Method Path Purpose
POST /auth/login/email Request an email OTP.
POST /auth/login/sms Request an SMS OTP.
POST /auth/login/verify Verify a code and receive a token.
POST /auth/refresh Refresh an existing user token.
DELETE /auth/logout Revoke the current token and its session.