Onboarding Flow¶
tortoise-auth provides a server-driven onboarding flow engine that guides users through multi-step registration. Instead of orchestrating register → verify email → setup TOTP via separate API calls, you expose a single endpoint and let the server tell the client what to render at each step.
The engine is inspired by Clerk, WorkOS, and Stytch — but runs entirely within your Tortoise ORM stack with no external dependencies.
Quick Start¶
from tortoise_auth import AuthConfig, OnboardingService
from tortoise_auth.onboarding.steps import RegisterStep, VerifyEmailStep
config = AuthConfig(
user_model="myapp.User",
signing_secret="your-secret-key",
jwt_secret="your-jwt-secret",
)
service = OnboardingService(
config,
steps={
"register": RegisterStep(),
"verify_email": VerifyEmailStep(),
},
pipeline=["register", "verify_email"],
)
# Start a new onboarding flow
result = await service.start("user@example.com")
print(result.session_token) # give this to the client
print(result.client_hint) # tells the client what form to render
How It Works¶
The onboarding flow is a state machine stored in the database:
-
start(email)— creates anOnboardingSession, finds the first required step, and returns aclient_hintdescribing what the client should display. -
advance(session_token, data)— executes the current step with the submitted data. On success, moves to the next step. On failure, returns errors and the sameclient_hintfor retry. -
resume(session_token)— returns the current step'sclient_hintwithout executing anything. Use this when the user returns after closing their browser. -
When all steps are complete,
advance()calls_finalize()which issues auth tokens viaAuthServiceand returns anOnboardingResultwithstatus="completed"and anauth_resultcontainingaccess_tokenandrefresh_token.
The client never needs to know the list of steps in advance. It simply
reacts to the client_hint returned by the server.
Client Server
│ │
│ POST /onboarding/start │
│ { email } │
│──────────────────────────────>│
│ │ create session
│ { client_hint: register } │ find first step
│<──────────────────────────────│
│ │
│ POST /onboarding/advance │
│ { email, password, ... } │
│──────────────────────────────>│
│ │ execute register step
│ { client_hint: verify_email }│ move to next step
│<──────────────────────────────│
│ │
│ POST /onboarding/advance │
│ { } (triggers code send) │
│──────────────────────────────>│
│ │ emit verification_code_generated
│ { client_hint: code input } │
│<──────────────────────────────│
│ │
│ POST /onboarding/advance │
│ { code: "123456" } │
│──────────────────────────────>│
│ │ verify code, finalize
│ { status: completed, │ issue tokens
│ auth_result: { tokens } } │
│<──────────────────────────────│
Configuration¶
All onboarding settings are part of AuthConfig:
| Option | Default | Description |
|---|---|---|
onboarding_session_lifetime |
3600 |
Session lifetime in seconds (1 hour) |
onboarding_session_token_length |
64 |
Length of the session token |
onboarding_require_totp |
False |
Whether TOTP setup is required (not just offered) |
onboarding_max_verification_attempts |
5 |
Max wrong verification codes before session is invalidated |
onboarding_verification_code_ttl |
600 |
Verification code lifetime in seconds (10 minutes) |
onboarding_invalidate_previous_sessions |
True |
Invalidate existing sessions for the same email on start() |
Pipeline Configuration¶
The pipeline parameter controls the order of steps. The steps dict
provides the implementations. Only steps in the pipeline are executed.
Register + Verify Email (minimal)¶
service = OnboardingService(
config,
steps={
"register": RegisterStep(),
"verify_email": VerifyEmailStep(),
},
pipeline=["register", "verify_email"],
)
With Optional TOTP¶
from tortoise_auth.onboarding.steps import SetupTOTPStep
config = AuthConfig(
user_model="myapp.User",
signing_secret="your-secret-key",
jwt_secret="your-jwt-secret",
onboarding_require_totp=False, # user can skip TOTP setup
)
service = OnboardingService(
config,
steps={
"register": RegisterStep(),
"verify_email": VerifyEmailStep(),
"setup_totp": SetupTOTPStep(),
},
pipeline=["register", "verify_email", "setup_totp"],
)
When onboarding_require_totp=False, SetupTOTPStep is skippable. The client
can call advance(token, {}, skip=True) to skip it.
When onboarding_require_totp=True, the step becomes required and cannot be
skipped.
With Profile Completion¶
from tortoise_auth.onboarding.steps import ProfileCompletionStep
service = OnboardingService(
config,
steps={
"register": RegisterStep(),
"verify_email": VerifyEmailStep(),
"profile": ProfileCompletionStep(
required_fields=["first_name", "last_name"],
optional_fields=["phone", "company"],
),
},
pipeline=["register", "verify_email", "profile"],
)
ProfileCompletionStep is automatically skippable when no required_fields
are configured.
Built-in Steps¶
RegisterStep¶
Creates a new user account. Validates email format, email uniqueness, password strength (using the configured password validators), and password confirmation.
Fields: email, password, password_confirm
Not skippable. Always required.
VerifyEmailStep¶
Two-phase email verification:
- Phase 1 (no
codein data): generates a signed 6-digit code and emitsverification_code_generated— your application must send the email. - Phase 2 (
codein data): verifies the code against the signed value and marksuser.is_verified = True.
Fields: code (phase 2 only)
Not skippable. Required unless step_data["email_verified"] is already
True.
You must handle email sending
tortoise-auth does not send emails. Listen for the
verification_code_generated event and send the code yourself:
SetupTOTPStep¶
Two-phase TOTP setup:
- Phase 1 (no
codein data): generates a TOTP secret and provisioning URI. The client can render a QR code from the URI. - Phase 2 (
codein data): verifies the TOTP code and persists the secret on the user model (if the model has atotp_secretfield).
Fields: code (phase 2 only)
Skippable (unless onboarding_require_totp=True).
Requires pyotp to be installed (pip install pyotp).
ProfileCompletionStep¶
Collects additional profile fields and updates the user model. Configurable
at construction time via required_fields and optional_fields.
Fields: dynamic, based on constructor arguments.
Skippable when no required_fields are set.
Custom Steps¶
Implement the OnboardingStep protocol to create your own steps:
from tortoise_auth.onboarding import (
ClientHint,
FieldHint,
OnboardingStep,
StepContext,
StepResult,
)
class AcceptTermsStep:
@property
def name(self) -> str:
return "accept_terms"
@property
def skippable(self) -> bool:
return False
async def is_required(self, context: StepContext) -> bool:
return True
async def execute(
self, context: StepContext, data: dict
) -> StepResult:
if not data.get("accepted"):
return StepResult(
success=False,
errors=["You must accept the terms of service"],
)
return StepResult(success=True, data={"terms_accepted": True})
def client_hint(self, context: StepContext) -> ClientHint:
return ClientHint(
step_name=self.name,
title="Terms of Service",
description="Please review and accept our terms.",
fields=[
FieldHint(
name="accepted",
field_type="checkbox",
label="I accept the terms of service",
),
],
extra={"terms_url": "https://example.com/terms"},
)
Then register it in your pipeline:
service = OnboardingService(
config,
steps={
"register": RegisterStep(),
"accept_terms": AcceptTermsStep(),
"verify_email": VerifyEmailStep(),
},
pipeline=["register", "accept_terms", "verify_email"],
)
Multi-Phase Steps¶
For steps that require multiple interactions (like verify email or TOTP setup),
return StepResult(success=True, completed=False) from the intermediate
phases. This tells the service to merge result.data into the session's
step_data but stay on the same step.
async def execute(self, context: StepContext, data: dict) -> StepResult:
if "code" not in data:
# Phase 1: generate something, stay on this step
return StepResult(
success=True,
completed=False,
data={"_generated_value": "..."},
)
# Phase 2: verify, complete the step
return StepResult(success=True, data={"verified": True})
Skipping Steps¶
Steps that have skippable=True can be skipped by the client:
Attempting to skip a non-skippable step returns an error:
result = await service.advance(session_token, {}, skip=True)
assert result.status == "error"
assert "cannot be skipped" in result.step_result.errors[0]
Session Management¶
Resuming a Flow¶
If the user closes their browser and comes back:
result = await service.resume(session_token)
# result.client_hint tells the client where they left off
Session Expiration¶
Sessions expire after onboarding_session_lifetime seconds (default: 1 hour).
Expired sessions raise OnboardingSessionExpiredError:
from tortoise_auth.exceptions import OnboardingSessionExpiredError
try:
result = await service.advance(session_token, data)
except OnboardingSessionExpiredError:
# prompt the user to start over
...
Session Invalidation¶
By default, calling start() for an email that already has an active session
invalidates the previous session. This prevents session fixation attacks and
handles the case where a user starts over.
Disable this with onboarding_invalidate_previous_sessions=False.
Cleanup¶
Delete expired sessions periodically:
Events¶
The onboarding engine emits lifecycle events that you can listen to:
| Event | Payload | When |
|---|---|---|
onboarding_started |
email, session_id, pipeline |
start() creates a session |
onboarding_step_completed |
session_id, step_name, user_id |
A step succeeds |
onboarding_step_skipped |
session_id, step_name |
A step is skipped |
onboarding_step_failed |
session_id, step_name, errors |
A step fails |
onboarding_completed |
user, session_id |
All steps done, tokens issued |
onboarding_session_expired |
session_id, email |
Expired session accessed |
verification_code_generated |
email, code |
Verify email step generates a code |
from tortoise_auth import on
@on("onboarding_completed")
async def welcome_user(*, user, session_id: str) -> None:
await send_welcome_email(user.email)
@on("onboarding_step_failed")
async def log_failure(*, session_id: str, step_name: str, errors: list) -> None:
logger.warning("Step %s failed for session %s: %s", step_name, session_id, errors)
Database Setup¶
Register the onboarding model module in your Tortoise configuration:
TORTOISE_ORM = {
"apps": {
"tortoise_auth": {
"models": [
"tortoise_auth.models.onboarding",
# ... other model modules
],
},
},
}
This creates the tortoise_auth_onboarding_sessions table.
Security¶
| Concern | How it's handled |
|---|---|
| Session token storage | SHA-256 hash stored in DB; raw token only on the client |
| Session fixation | start() invalidates previous sessions for the same email |
| Brute-force verification codes | Attempt counter; session invalidated after max attempts |
| Code expiration | Verification codes are signed with TimestampSigner and expire after onboarding_verification_code_ttl |
| Expired sessions | is_valid checks expiry on every advance() and resume() |
| Concurrent sessions | Only the most recent session for an email is valid |
Error Handling¶
from tortoise_auth.exceptions import (
OnboardingError,
OnboardingFlowCompleteError,
OnboardingSessionExpiredError,
OnboardingSessionInvalidError,
)
try:
result = await service.advance(token, data)
except OnboardingSessionExpiredError:
# 410 Gone — session expired, start over
...
except OnboardingSessionInvalidError:
# 404 Not Found — session not found or invalidated
...
except OnboardingFlowCompleteError:
# 409 Conflict — flow already completed
...
except OnboardingError:
# catch-all for any onboarding error
...
Full Example with Starlette¶
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from tortoise_auth import AuthConfig, OnboardingService
from tortoise_auth.exceptions import (
OnboardingFlowCompleteError,
OnboardingSessionExpiredError,
OnboardingSessionInvalidError,
)
from tortoise_auth.onboarding.steps import RegisterStep, VerifyEmailStep
config = AuthConfig(
user_model="myapp.User",
signing_secret="your-secret-key",
jwt_secret="your-jwt-secret",
)
onboarding = OnboardingService(
config,
steps={
"register": RegisterStep(),
"verify_email": VerifyEmailStep(),
},
pipeline=["register", "verify_email"],
)
async def start(request: Request) -> JSONResponse:
body = await request.json()
result = await onboarding.start(
body["email"],
ip_address=request.client.host,
)
return JSONResponse({
"session_token": result.session_token,
"current_step": result.current_step,
"client_hint": _serialize_hint(result.client_hint),
})
async def advance(request: Request) -> JSONResponse:
body = await request.json()
token = body.pop("session_token")
skip = body.pop("skip", False)
try:
result = await onboarding.advance(token, body, skip=skip)
except OnboardingSessionExpiredError:
return JSONResponse({"error": "Session expired"}, status_code=410)
except OnboardingSessionInvalidError:
return JSONResponse({"error": "Session not found"}, status_code=404)
except OnboardingFlowCompleteError:
return JSONResponse({"error": "Already completed"}, status_code=409)
response = {
"status": result.status,
"current_step": result.current_step,
"client_hint": _serialize_hint(result.client_hint),
"completed_steps": result.completed_steps,
"remaining_steps": result.remaining_steps,
}
if result.step_result:
response["errors"] = result.step_result.errors
if result.auth_result:
response["access_token"] = result.auth_result.access_token
response["refresh_token"] = result.auth_result.refresh_token
return JSONResponse(response)
def _serialize_hint(hint):
if hint is None:
return None
return {
"step_name": hint.step_name,
"title": hint.title,
"description": hint.description,
"skippable": hint.skippable,
"fields": [
{
"name": f.name,
"type": f.field_type,
"required": f.required,
"label": f.label,
"placeholder": f.placeholder,
}
for f in hint.fields
],
"extra": hint.extra,
}
app = Starlette(routes=[
Route("/onboarding/start", start, methods=["POST"]),
Route("/onboarding/advance", advance, methods=["POST"]),
])