Stateless OTP Login in FastAPI with JWT and Redis
Railway removed passwords entirely. You enter your email, get a six-digit code, and you are in. No password field, no "forgot password" flow, no credential database to rotate when the breach happens. This post implements the same pattern in FastAPI: a stateless OTP login that stores nothing in the database, embeds the code in a signed JWT, and enforces single-use via Redis. The full flow is under 120 lines of application code.
Prerequisites¶
- Python 3.11+
- FastAPI 0.115+
python-jose,redis,aiosmtplib,pydantic-settingsinstalled- Working knowledge of JWT claims and Redis key expiry
Versions used in this post
FastAPI 0.135 · python-jose 3.3 · aiosmtplib 3.x · Redis 7
Background¶
The naive OTP approach writes a code and an expiry timestamp to the database on every login request, then does a read-then-delete on verification. It works, but it adds two database operations to the critical path and leaks codes if the database is compromised.
The stateless alternative embeds the code inside a signed JWT. The server signs the token, returns it to the client, and verifies the signature on submit. No row written. No query on verify. The token is its own proof of issuance.
The most common implementation mistake
Signing OTP tokens with the same secret as access tokens means a valid OTP token decodes wherever the server accepts a bearer token. OTP tokens must use a separate derived secret and carry a type: otp claim so the two token types cannot be substituted for each other.
Implementation¶
1. Token creation and code generation¶
Derive a separate OTP secret from the application secret by appending a fixed suffix. Tokens carry a type: otp claim so they are rejected by the access token verifier even if submitted to a protected route.
OTP_EXPIRE_MINUTES = 10
OTP_ALGORITHM = "HS256"
OTP_SECRET = f"{settings.SECRET_KEY}-otp"
def generate_otp_code() -> str:
return "".join(random.choices(string.digits, k=6))
def create_otp_token(email: str, code: str, extra_claims: dict | None = None) -> str:
payload = {
"sub": email,
"code": code,
"exp": datetime.now(timezone.utc) + timedelta(minutes=OTP_EXPIRE_MINUTES),
"type": "otp",
**(extra_claims or {}),
}
return jwt.encode(payload, OTP_SECRET, algorithm=OTP_ALGORITHM)
The derived secret and the type claim are independent guards. Decoding an OTP token with the main secret fails at signature verification. Submitting an OTP token to a protected route fails at the type check. Both must hold.
2. Request endpoint¶
The request endpoint generates a code, signs it into a token, sends the email, and returns the token to the client. It always returns success regardless of whether the email is registered. The email send fires only for known addresses, but the response is identical either way.
@router.post("/otp/request")
async def request_otp(payload: OTPRequest):
return await auth_service.request_otp(payload.email)
async def request_otp(self, email: str) -> dict:
code = generate_otp_code()
token = create_otp_token(email, code)
await send_otp_email(email, code)
return {"message": "Code sent. Check your email.", "verification_token": token}
The client stores the verification_token from the response and submits it alongside the code on the next request. The server stores neither.
3. Verify endpoint¶
async def verify_otp(
self, email: str, code: str, verification_token: str, redis
) -> dict:
blacklist_key = f"otp_used:{verification_token}"
if await redis.get(blacklist_key):
raise UnAuthorizedException(detail="Code has already been used.")
try:
payload_data = decode_otp_token(verification_token)
except Exception:
raise UnAuthorizedException(detail="Invalid or expired verification token.")
embedded_code: str = payload_data["code"]
if not hmac.compare_digest(embedded_code, code):
raise UnAuthorizedException(detail="Incorrect code.")
exp = payload_data.get("exp", 0)
ttl = max(1, exp - int(time_module.time()))
await redis.set(blacklist_key, "1", ttl=ttl)
return {
"message": "Verified successfully.",
"access_token": create_access_token(subject=email),
"refresh_token": create_refresh_token(subject=email),
"token_type": "bearer",
}
Three things happen in a specific order.
The Redis blacklist check runs before any token decoding. This prevents a timing oracle: an attacker submitting a used token repeatedly and measuring decode timing differences learns nothing if the check is a flat cache lookup first.
hmac.compare_digest does constant-time string comparison. A plain == leaks the length of the match through timing. compare_digest does not.
The blacklist TTL is derived from the token's remaining lifetime, not a fixed value. A hardcoded TTL shorter than the token's actual expiry leaves a window where the token is still valid but the blacklist entry has expired. Always use exp - time.time().
4. Token decode with type guard¶
def decode_otp_token(token: str) -> dict:
try:
payload = jwt.decode(token, OTP_SECRET, algorithms=[OTP_ALGORITHM])
if payload.get("type") != "otp":
raise JWTError("Invalid token type.")
return payload
except JWTError as e:
raise JWTError(str(e))
The type guard rejects access tokens submitted to the OTP verify endpoint. Without it, an attacker with a valid access token could craft a payload containing a known code claim and pass verification.
Testing and Verification¶
Run the full flow against a local Mailpit instance:
# Step 1: request a code
curl -s -X POST http://localhost:8000/api/v1/auth/otp/request \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com"}' | jq .
Expected response:
{
"message": "Code sent. Check your email.",
"verification_token": "eyJhbGciOiJIUzI1NiIsInR5..."
}
# Step 2: open Mailpit at http://localhost:8025, copy the code, then verify
curl -s -X POST http://localhost:8000/api/v1/auth/otp/verify \
-H "Content-Type: application/json" \
-d '{
"email": "alice@example.com",
"code": "482916",
"verification_token": "eyJhbGciOiJIUzI1NiIsInR5..."
}' | jq .
Expected response:
{
"message": "Verified successfully.",
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"token_type": "bearer"
}
To verify secret isolation, decode a valid access token and submit it to /otp/verify. It will fail at decode_otp_token because the OTP secret does not match the access token secret.
Pitfalls¶
Reusing the main SECRET_KEY for OTP tokens
Signing OTP tokens with the same key as access tokens means either token type decodes with the other's verifier. An attacker who extracts a valid access token can craft a payload with a known code claim and pass the verify endpoint. Fix: derive the OTP secret with a fixed suffix, SECRET_KEY + "-otp", and enforce the type: otp claim on decode.
Fixed blacklist TTL shorter than token lifetime
await redis.set(blacklist_key, "1", ex=180) with a 10-minute OTP token leaves a 7-minute window where the token is still valid but the blacklist entry has expired. The used token becomes replayable. Always derive TTL from max(1, exp - int(time.time())).
User enumeration through response timing
If request_otp skips create_otp_token for unregistered addresses but calls it for known ones, response times differ. Run the same code path for every address and swallow the send error silently for unknown emails.
Production Considerations¶
The OTP email is sent in the request path because the user is waiting for it. A Celery task adds latency that the user cannot observe and gains nothing. If SMTP latency becomes a bottleneck at high volume, move the send to Celery and return the verification_token immediately, then surface delivery status via a polling endpoint.
The six-digit numeric space is 10^6 combinations. With a 10-minute expiry and the token bound to a specific email, brute force is not practical. Rate-limiting /otp/verify per IP closes it entirely regardless.
Wrapping Up¶
The OTP state lives in a signed JWT. Redis enforces single-use. No database table is involved and no secret is stored at rest. The entire flow across otp.py, auth_service.py, and the two route handlers is under 120 lines.
The next problem you will hit is delivery reliability. SMTP against a transactional provider (Postmark, Resend, or SendGrid) adds delivery webhooks and reduces latency compared to a self-hosted relay.
- Full source: github.com/joseph-fidelis