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:
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()
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. |