Email OTP¶
Passwordless email sign-in with a numeric one-time password (OTP). The user requests a code by email, then signs in by submitting it. Unknown emails are signed up on first success unless signup is disabled.
Install¶
Included in the core install. No extra is required.
Delivery is your responsibility: pass an EmailSender as email_sender= to db.Auth. Without one, the send endpoint still stores the code but no email is dispatched (useful in tests).
Setup¶
import deadbolt as db
from deadbolt.plugins.email_otp import email_otp
auth = db.Auth(
adapter=db.MemoryAdapter(),
secret=SECRET,
email_and_password=db.EmailPassword(enabled=True),
email_sender=my_email_sender,
plugins=[email_otp()],
)
Configuration¶
| Parameter | Type | Default | Description |
|---|---|---|---|
length |
int | 6 |
Number of digits in the generated code. |
ttl |
int | 300 |
Code time-to-live in seconds (5 minutes by default). |
max_attempts |
int | 3 |
Failed verification attempts allowed before the code is consumed. |
disable_signup |
bool | False |
When True, sign-in for an unknown email is rejected instead of creating a user. |
API¶
POST /email-otp/send¶
Generates a numeric code, stores its hash (with attempts reset to 0 and a fresh expiry) in the email_otp table keyed by email, and emails the raw code via the configured EmailSender. A resend replaces any existing code for that email. Auth: public.
Request (body JSON):
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | Email address to send the code to. Lowercased before storage. |
Response 200:
Errors:
| Status | Code | When |
|---|---|---|
| 400 | invalid_request |
email field missing or not a non-empty string. |
Example:
curl -X POST https://api.example.com/email-otp/send \
-H "Content-Type: application/json" \
-d '{"email": "a@b.com"}'
POST /sign-in/email-otp¶
Verifies an emailed code. Checks the code against the stored hash in constant time, enforces the attempts limit, then finds or creates the user, marks the email verified, deletes the code, and creates a session. Auth: public.
Request (body JSON):
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | Email the code was sent to. Lowercased before lookup. |
otp |
string | Yes | The numeric code from the email. |
Response 200:
{
"user": {
"id": "usr_123",
"email": "a@b.com",
"email_verified": true,
"name": null,
"image": null,
"created_at": "2026-07-08T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z"
}
}
A session cookie is set in the response.
Errors:
| Status | Code | When |
|---|---|---|
| 400 | invalid_request |
email or otp field missing or empty. |
| 400 | invalid_otp |
No code on file, code expired, wrong code, attempts exhausted, or unknown email with disable_signup=True. |
Example:
curl -X POST https://api.example.com/sign-in/email-otp \
-H "Content-Type: application/json" \
-d '{"email": "a@b.com", "otp": "123456"}'
Notes¶
- Hashed at rest: only the code hash (
hash_token) is stored in theemail_otptable; the raw code exists only in the email. - Single-use: the OTP row is deleted on successful sign-in, so a code cannot be replayed — a second sign-in returns
invalid_otp. - Timing-safe comparison: submitted codes are compared with
tokens_equal(constant-time) against the stored hash. - Attempts limit: each wrong code increments
attempts. Once the next failure would reachmax_attempts, the code is deleted, so even the correct code afterwards fails withinvalid_otp. - Resend replaces: sending again for the same email overwrites the existing code and resets attempts to 0 rather than creating a second row.
- Find-or-create: an unknown email creates a new user (
name=None) on success; withdisable_signup=True, unknown emails are rejected asinvalid_otp. - Email marked verified: a successful sign-in sets
email_verified = trueon the user. - TTL: codes expire after
ttlseconds (default 300); expired codes are rejected asinvalid_otp.