Why AllSafe Fast?
Authentication looks simple on the surface — a login form, a password check, a redirect. But the moment you start building it yourself in FastAPI, you discover a long list of decisions, edge cases, and security traps. Each one is a place to get it wrong. AllSafe Fast exists so you don't have to.
This page walks through every concern you'd otherwise handle by hand, explains why it's hard, and shows how AllSafe Fast turns it into a one-liner or a sensible default.
Password Hashing
Storing passwords safely means choosing a hashing algorithm, managing salts, and tuning parameters. Pick bcrypt and you're fine — until you learn Argon2id is the current recommendation. Manage your own salt and you'll almost certainly do it wrong. Set the cost factor too low and you're vulnerable; too high and login takes seconds.
# The manual way — many ways to get this wrong
import bcrypt
def hash_password(password: str) -> str:
salt = bcrypt.gensalt(rounds=12) # is 12 enough? too slow?
return bcrypt.hashpw(password.encode(), salt).decode()
def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode(), hashed.encode())
# what if hashed is None? what about timing attacks?
AllSafe Fast uses Argon2id by default — the algorithm recommended by OWASP and the Password Hashing Competition. Salts are generated and embedded automatically. Parameters (time cost, memory cost, parallelism) are preconfigured to safe values and exposed for tuning. Verification uses constant-time comparison to prevent timing attacks.
Password hashing is handled entirely by the Password provider. You never call a hashing function directly. The provider accepts a plaintext password during sign-up, hashes it with Argon2id, and stores only the hash. On sign-in, it verifies the submitted password against the stored hash in constant time.
Sessions
Once a user logs in, you need to remember them across requests. The classic dilemma: server-side sessions or stateless JWTs? Server-side sessions require a store (Redis, database), cleanup of expired entries, and a lookup on every request. JWTs are self-contained but introduce their own problems — you can't revoke them without a blocklist, and claims go stale when roles change.
Then there's storage: where do you put the token? LocalStorage is vulnerable to XSS. Cookies need HttpOnly, Secure, and SameSite flags — and if you use cookies, you need CSRF protection. And what about cleanup? Expired sessions pile up in your database unless you prune them.
AllSafe Fast uses a hybrid model: short-lived JWT access tokens (15 minutes) for the fast path, plus long-lived refresh tokens (30 days) stored as SHA-256 hashes in the database. Access tokens are self-contained — no database query on every request. Refresh tokens are rotatable and revocable. Cookies are configured with HttpOnly, Secure, and SameSite=Lax by default, and CSRF protection is automatic when cookies are used.
Tokens
Tokens bring their own set of questions. Do you need both access and refresh tokens? How do you rotate refresh tokens without invalidating active sessions? How do you detect token theft? What happens when a refresh token is reused — is it a bug, an attack, or a user with two tabs open?
# The manual way — rolling your own refresh logic
async def refresh(refresh_token: str):
session = await db.get_session(refresh_token)
if not session:
raise HTTPException(401)
# is this token being reused? how would you know?
# should you revoke all the user's sessions now?
# what if the session is expired vs revoked vs stolen?
new_access = create_jwt(session.user_id)
return {"access_token": new_access}
AllSafe Fast implements refresh token rotation with automatic theft detection. When a refresh token is used, the old session is revoked and a new one is created. If a revoked refresh token is ever presented again, the framework treats it as theft: all of that user's sessions are immediately revoked, and a TokenReuseError is raised. This is the pattern recommended by OAuth 2.0 best practices, implemented correctly.
Rotation alone isn't enough. If an attacker steals a refresh token and uses it before the legitimate user does, the user's next refresh attempt will fail — but the attacker now has a valid session. AllSafe Fast detects reuse of a revoked token and revokes every session for that user, cutting off the attacker immediately.
User Management
Registration seems straightforward until you handle the edge cases: duplicate emails, email normalization (is User@Example.com the same as user@example.com?), verification flows, and what to do when an OAuth provider returns an email that matches an existing local account. Do you auto-merge? (You shouldn't — that's an account takeover vulnerability.)
Then there's profile management: updating a user's name, marking their email as verified, assigning roles and permissions. Each of these needs to be atomic, validated, and auditable.
AllSafe Fast never auto-merges accounts based on email match alone. If someone signs in with Google and their email matches an existing user, a new account is linked to that user — but only during the authenticated flow. Explicit account linking requires proof that the requester owns the existing account. This prevents account takeover via provider manipulation.
The IdentityService resolves provider identities to local users, normalizes emails to lowercase, and safely updates profiles when new verified information arrives from a provider. The AccountService manages the link between users and providers, enforcing the no-auto-merge policy.
Auth Dependencies
In FastAPI, extracting the current user means writing a dependency that pulls the token from the request, validates it, and returns a user object. Do this once and it's fine. Do it across a dozen route files and you've duplicated logic, error handling, and token extraction everywhere. And if you need to add a verified=True check or a role requirement, you're writing a new dependency for each variation.
# The manual way — a new dependency for every requirement
async def get_current_user(token: str = Depends(oauth2_scheme)):
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
user = await db.get_user(payload["sub"])
return user
async def get_current_admin(user=Depends(get_current_user)):
if "admin" not in user.roles:
raise HTTPException(403)
return user
async def get_verified_user(user=Depends(get_current_user)):
if not user.email_verified:
raise HTTPException(403)
return user
AllSafe Fast gives you a single dependency factory: auth.user(). Call it with no arguments for any authenticated user. Add role="admin" to require a role. Add permission="reports:export" to require a permission. Add verified=True to require a verified email. The dependency handles token extraction, JWT verification, principal construction, and authorization checks — all in memory, all on every request.
# The AllSafe Fast way — one factory, every requirement
@app.get("/me")
async def me(user=auth.user()):
return user
@app.get("/admin")
async def admin(user=auth.user(role="admin")):
return user
@app.get("/verify-required")
async def verified(user=auth.user(verified=True)):
return user
Authorization
Authentication tells you who the user is. Authorization tells you what they can do. Rolling your own means defining a roles model, a permissions model, and a policy enforcement point — then wiring them into every route. It's easy to forget a check, apply it inconsistently, or put it in the wrong layer.
AllSafe Fast bakes authorization into the same dependency that authenticates. The AuthorizationService performs stateless checks against the UserPrincipal built from JWT claims — roles and permissions are embedded in the token, so no database query is needed. Checks include check_role, check_permission, check_any_role, check_all_permissions, and check_verified. All raise AuthorizationError (mapped to 403) on failure.
Security Configuration
Secure defaults are easy to get wrong. Cookies without HttpOnly expose tokens to XSS. Cookies without Secure leak over HTTP. Missing SameSite invites CSRF. A default or hardcoded secret key is catastrophic. Rate limiting that doesn't exist lets attackers brute-force passwords. And none of these errors produce a visible error — your app runs fine until it's exploited.
AllSafe Fast refuses to start in production (ALLSAFE_ENV=production) with a default secret key, insecure cookies, or an insecure configuration. The allsafe doctor command checks your configuration and reports issues before you deploy. It's better to fail at startup than to ship a vulnerability.
Every security decision defaults to the safest option: HttpOnly + Secure + SameSite=Lax cookies, HS256 JWT signing with a generated secret, SHA-256 hashed token storage, CSRF headers required for cookie-based auth, and a sliding-window rate limiter for auth endpoints.
Database Integration
Auth needs tables: users, accounts, sessions, verifications. You need a schema, migrations, and ORM models that don't fight your existing models. If you're using SQLAlchemy, you need async support. If you're using something else, you need an abstraction that doesn't lock you in.
AllSafe Fast defines four typing.Protocol storage interfaces — UserStoreProtocol, AccountStoreProtocol, SessionStoreProtocol, VerificationStoreProtocol. Two implementations ship: an in-memory store (for development and testing) and an async SQLAlchemy store (for production with PostgreSQL). The CLI generates migrations with allsafe db generate and applies them with allsafe db migrate. Domain models are plain dataclasses — no ORM inheritance — so the storage layer converts between ORM and domain objects cleanly.
Error Handling
Consistent error responses matter. A login endpoint that returns 200 with {"error": "bad password"} on failure and 400 with {"detail": "Invalid credentials"} on another path is a mess for clients to consume. Worse, returning different messages for "user not found" vs "wrong password" lets attackers enumerate accounts.
# The manual way — inconsistent and leaky
if not user:
return {"error": "user not found"} # enumeration!
if not verify_password(password, user.hash):
return {"error": "wrong password"} # tells attacker user exists
AllSafe Fast returns consistent JSON error responses with stable status codes: 401 for authentication failures, 403 for authorization failures, 409 for conflicts, 422 for validation errors. Login failures always return the same message regardless of whether the email or password was wrong — preventing user enumeration. All exceptions are typed (AuthenticationError, AuthorizationError, TokenInvalidError, TokenExpiredError, TokenReuseError, ConflictError, etc.) so you can catch them precisely.
Sign-up, login, and password-reset endpoints all return generic messages that don't reveal whether an email is registered. A failed login returns "Invalid credentials" whether the email doesn't exist or the password is wrong. A password-reset request always returns success, even for unknown emails — the email simply isn't sent.
The Bottom Line
Every concern on this page is a thing you'd otherwise build, test, and maintain yourself. Each one is a potential vulnerability if you get it slightly wrong. AllSafe Fast handles all of them with secure defaults, a clean API, and the flexibility to override anything when your needs diverge from the defaults.
You shouldn't have to be a security expert to ship safe authentication. With AllSafe Fast, you don't.