pyxle-auth
pyxle-auth is Pyxle's official authentication plugin: email- or username-based password accounts, sliding sessions, password-reset and email-verification flows, role-based access control, and scoped API tokens — wired into your app with one config entry. It never sends email and never renders UI; it gives you hardened primitives and stays out of your templates.
Version 0.4.0. Runs on pyxle-db (SQLite, PostgreSQL, or MySQL — the full account lifecycle is tested against real servers in CI), or on any database layer satisfying the
DatabaseLikecontract.
Install
pip install pyxle-authThis pulls in pyxle-db and argon2-cffi. There are no other dependencies.
Quickstart
List pyxle-db before pyxle-auth in pyxle.config.json — the auth services run on the database that plugin opens:
{
"plugins": [
"pyxle-db",
"pyxle-auth"
]
}That's the whole wire-up. At startup the plugin applies its bundled migrations (idempotent, checksum-tracked, in its own schema_migrations_pyxle_auth tracking table so they never collide with your app's migrations) and registers its services.
Protect a page with a guard in its @server loader:
# pages/dashboard.pyxl — Python section
from pyxle_auth import require_user_page
@server
async def load(request):
user = await require_user_page(request) # 401 → error boundary when signed out
return {"email": user.email, "plan": user.plan}Sign-in must put a Set-Cookie header on the response, so it lives in an API route — actions return plain JSON payloads and can't attach cookies:
# pages/api/sign_in.py
from starlette.requests import Request
from starlette.responses import JSONResponse
from pyxle_auth import AuthError, RateLimited, get_auth_service
async def endpoint(request: Request) -> JSONResponse:
body = await request.json()
auth = get_auth_service()
try:
user, cookie = await auth.sign_in(
email=body["email"],
password=body["password"],
ip=request.client.host,
user_agent=request.headers.get("user-agent", ""),
)
except RateLimited as exc:
return JSONResponse(
{"ok": False, "error": str(exc)},
status_code=429,
headers={"Retry-After": str(exc.retry_after_seconds)},
)
except AuthError as exc:
# InvalidCredentials and friends share one deliberately vague
# message — don't replace it with something more "helpful".
return JSONResponse({"ok": False, "error": str(exc)}, status_code=401)
response = JSONResponse({"ok": True, "userId": user.id})
response.set_cookie(**cookie.kwargs())
return responsesign_up has the same shape and also returns (user, cookie). sign_out(cookie_value=...) returns a cookie that clears the browser's copy — set it the same way.
Where accounts live
pyxle-auth stores its records — users, sessions, rate-limit buckets, tokens, roles — in whatever database pyxle-db opens. With no pyxle-db settings, that's a SQLite file auto-created at data/app.db on first run: the plugin creates the directory and the file, and pyxle-auth's bundled migrations build its tables at startup. There is no manual migration or create-db step — the Quickstart config above boots against an empty project and just works.
For production, point pyxle-db at your real database and pyxle-auth follows — its migrations apply there at startup exactly as they do to SQLite:
{
"plugins": [
{ "name": "pyxle-db", "settings": { "url": "env:DATABASE_URL" } },
"pyxle-auth"
]
}The env: indirection keeps credentials out of the committed config — see pyxle-db → Plugin settings. Remember the SQLite file is your user table: back it up like one, and never commit or deploy a local data/app.db over a production database. (To back pyxle-auth with something other than pyxle-db entirely, see Bring your own database.)
Identity model: email or username
By default accounts are identified by email — exactly as the examples above. To build a username-based app (any available handle, no email or phone required), set identifier to "username":
{
"plugins": [
"pyxle-db",
{ "name": "pyxle-auth", "settings": { "identifier": "username" } }
]
}Now the services, the credential endpoints, and useAuth() take a username instead of an email:
user, cookie = await auth.sign_up(username="ada", password="…") # email optional
user = await auth.verify_credentials(username="ADA", password="…") # case-insensitive
is_free = await auth.username_available("ada") # False- Normalised: usernames are trimmed and lowercased, so uniqueness is case-insensitive on every backend (
Adaandadaare one account). - Policy (configurable):
usernameMinLength/usernameMaxLength(3–30),usernamePattern(^[a-z0-9_-]+$), and a reserved-name block-list (~90 system/route names likeadmin,api,login) that blocks impersonation and route collisions. - Availability is public but per-IP rate-limited (
rateLimitUsernameCheckPerHour, default 120) so it can't bulk-scrape the user list:GET /auth/username-available?u=<name>returns{ "available": true | false }— plus an optional human-readablereason(e.g. for reserved names) your picker UI can surface directly, so a picker can check live as the user types. Sign-in stays enumeration-safe — a missing or malformed handle is an ordinaryInvalidCredentials, never a distinct error. - Optional email: username mode may still accept an email at sign-up (e.g. for a future reset) — pass both; only the configured identifier is required.
- No per-person limit: anyone can register as many usernames as they like; tune
rateLimitSignUpPerHourif you want to throttle.
Internally users.email and users.username are both nullable, UNIQUE columns, so switching modes is a config change — existing email apps are untouched. Email-only flows (verification, password reset) simply go unused in username mode.
Plugin services
| Service name | Type | What it does |
|---|---|---|
auth.service |
AuthService |
Accounts, sessions, password flows. |
auth.rbac |
RoleService |
Roles and permissions. |
auth.tokens |
TokenService |
Single-use tokens (resets, invites, magic links). |
auth.api_tokens |
ApiTokenService |
Long-lived pyxle_pat_ bearer tokens. |
auth.settings |
AuthSettings |
The resolved configuration. |
get_auth_service() and get_auth_settings() are typed shortcuts for the first and last.
How sessions work
- The browser holds 32 random bytes (256-bit entropy) in an
HttpOnlycookie — default namepyxle_session. - The database stores only the SHA-256 hash of that value, so a leaked database cannot resurrect sessions.
- Sessions are sliding: each resolved request can extend the expiry (
resolve_session(cookie_value=..., extend=True)), up to an absolute maximum age. Defaults: 30-day sliding window, 90-day absolute cap. revoke_all_sessions(user_id=...)signs out everywhere;list_sessions/revoke_sessionpower a "manage devices" page. Password changes and resets revoke every session automatically.
Session middleware, request.user, and useAuth()
Listing the plugin installs AuthSessionMiddleware automatically. On every request it resolves the session cookie and sets request.user (a User, or None when anonymous) — so loaders and actions can read the signed-in user without calling a guard. A request without the cookie does zero database work; one with it does a single indexed lookup that the guards then reuse, so a guarded loader never resolves the session twice.
The middleware also serves the endpoints the client useAuth() hook talks to, under authPathPrefix (default /auth):
| Endpoint | Purpose |
|---|---|
GET /auth/me |
The current user as JSON. |
POST /auth/login |
Sign in with { email, password }. (opt-out) |
POST /auth/signup |
Create an account with { email, password }. (opt-out) |
POST /auth/logout |
Revoke the session and clear the cookie. |
/login and /signup reuse AuthService.sign_in / sign_up — same rate limiting, same enumeration-safe errors — and map failures to status codes (401 invalid credentials, 409 account exists, 422 weak password, 403 unverified email, 429 rate limited with Retry-After). They are state-changing POSTs, so the framework's CSRF protection applies; useAuth sends the token for you. Set enableCredentialsApi: false to turn them off and drive sign-in from your own @action instead (then call useAuth().refresh()); /me and /logout stay available.
In username mode (identifier: "username", see Identity model) these endpoints take { username, password } instead of { email, password }, and a third endpoint — GET /auth/username-available?u=<name> — is served for live availability checks. The SSR seed publishes the active identifier so useAuth() and your form know which field to render.
useAuth() returns the full auth surface:
| Field | Type | Description |
|---|---|---|
user |
object | null |
The signed-in user, or null. |
isAuthenticated |
boolean |
Whether a user is signed in. |
loading |
boolean |
true while an auth request is in flight. |
error |
string | null |
The last auth error message, or null. |
login |
({ email, password }) => Promise |
Sign in via POST /auth/login. |
signup |
({ email, password }) => Promise |
Create an account via POST /auth/signup. |
logout |
() => Promise |
Revoke the session and clear the cookie. |
refresh |
() => Promise |
Re-fetch /auth/me (call after driving sign-in from your own @action). |
// A whole auth UI, client-side:
import { useAuth } from 'pyxle/client';
function Account() {
const { user, isAuthenticated, login, signup, logout } = useAuth();
if (isAuthenticated) {
return <button onClick={() => logout()}>Sign out {user.email}</button>;
}
return (
<>
<button onClick={() => login({ email, password })}>Sign in</button>
<button onClick={() => signup({ email, password })}>Create account</button>
</>
);
}The signed-in user is seeded into the page (window.__PYXLE_AUTH__), so useAuth resolves without a network round-trip and shows the right state from the first post-hydration frame. The server-rendered markup itself renders the logged-out state (session-aware SSR is on the roadmap) — gate any flash-sensitive UI on useAuth().loading, or render it client-side.
CSRF
POST /auth/login, /auth/signup, and /auth/logout are state-changing endpoints, so the framework's CSRF protection guards them like any other POST — a request that doesn't carry the token is rejected with 403 ("CSRF token missing"). useAuth() handles this for you; you only deal with it when calling the endpoints directly (curl, an HTTP client, an integration test).
The dance is the standard double-submit pattern: any ordinary GET response issues the Pyxle CSRF cookie (responses marked publicly cacheable skip it), and the POST must echo that cookie's value back in the X-CSRF-Token header (alongside the cookie itself):
# 1. Any GET issues the CSRF cookie — capture it in a cookie jar
curl -s -c jar.txt http://localhost:8000/auth/me > /dev/null
# 2. The token is the value of the Pyxle CSRF cookie
TOKEN=$(awk '$6 ~ /csrf/ { print $7 }' jar.txt)
# 3. POST with the cookie jar, echoing the token in the header
curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "correct horse battery"}' \
http://localhost:8000/auth/loginThe cookie and header names (and how to customise or exempt paths from the check) are documented in Security → CSRF protection. For token-authenticated API clients that can't run this dance, see the exempt-paths note under JWT.
Guards
Drop-in checks for loaders, actions, and API routes:
| Guard | Behaviour |
|---|---|
current_user(request) |
User or None — never raises. |
require_user_page(request) |
User, or raises LoaderError(401) for @server loaders. |
require_user_action(request) |
User, or raises ActionError(401) for @action handlers. |
require_permission_page(request, permission) |
User must hold the permission, else 401/403. |
require_permission_action(request, permission) |
Same, for actions. |
bearer_token(request) |
Extracts a Bearer token from the Authorization header, or None. |
The roadmap-named aliases login_required / login_required_action and permission_required / permission_required_action are the same functions as require_user_* / require_permission_* — call them at the top of a loader or action (Pyxle guards are awaited, not wrapping decorators):
from pyxle_auth import login_required
@server
async def load(request):
user = await login_required(request) # raises LoaderError(401) when anonymous
return {"email": user.email}Passwords
Hashing is argon2id via argon2-cffi. Defaults: time cost 3, memory 64 MiB, parallelism 2. In strict mode (the default) the settings refuse to construct with parameters below the floor (time cost ≥ 2, memory ≥ 19 MiB), so a typo'd config can't silently weaken hashing.
Two deliberate guards worth knowing about:
- Passwords longer than
passwordMaxLength(default 1024) are rejected before hashing — unbounded input would make argon2 itself a denial-of-service vector. - Sign-in burns the same argon2 verification cost whether or not the account exists, so response timing doesn't leak which emails are registered.
Password reset and email verification
pyxle-auth never sends email. Flows that need delivery return a raw, single-use token exactly once; your app puts it in a link and hands it to whatever mailer it already uses:
# pages/api/forgot_password.py
async def endpoint(request: Request) -> JSONResponse:
body = await request.json()
auth = get_auth_service()
result = await auth.request_password_reset(
email=body["email"], ip=request.client.host
)
if result is not None:
user, token = result
await my_mailer.send(
to=user.email,
subject="Reset your password",
body=f"https://example.com/reset?token={token}",
)
# Same response whether the account exists or not — this endpoint
# must not be usable to probe for accounts.
return JSONResponse({"ok": True, "message": "Check your inbox."})The user completes the flow with reset_password(raw_token=..., new_password=...), which burns the token and revokes every session. Email verification mirrors the pattern: request_email_verification(user_id=...) returns a token, confirm_email(raw_token=...) redeems it. Both raise InvalidToken for anything stale, used, unknown, or wrong-purpose — indistinguishably.
Tokens are stored hashed (SHA-256), are single-use, and requesting again invalidates earlier tokens for the same purpose. The unknown-email path of request_password_reset performs the same committed token write a real account does, so even its timing doesn't enumerate accounts.
For your own flows (invite links, magic links), the same machinery is exposed as auth.tokens: issue(purpose=..., user_id=..., ttl_seconds=...), consume(purpose=..., raw_token=...), sweep_expired().
Roles and permissions
A small RBAC layer with wildcard grants:
from pyxle.plugins import plugin
rbac = plugin("auth.rbac")
await rbac.define_role(name="admin", permissions=["projects.*", "billing.read"])
await rbac.grant_role(user_id=user.id, role_name="admin")
await rbac.has_permission(user_id=user.id, permission="projects.deploy") # TruePermissions are dot-separated strings; a role can hold exact permissions, "prefix.*" wildcards, or the global "*". Also available: revoke_role, delete_role, roles_for, users_with_role, and permissions_for (the user's full effective set). Granting twice is idempotent.
API tokens
Long-lived bearer credentials for CLIs and CI, prefixed pyxle_pat_ so secret scanners can recognise them:
api_tokens = plugin("auth.api_tokens")
token, raw = await api_tokens.create(
user_id=user.id,
name="deploy bot",
scopes=["deploy"],
expires_in_days=90, # optional — None means no expiry
max_tokens_per_user=10, # optional plan cap, enforced atomically
)
# `raw` is shown exactly once; only its hash is stored.
resolved = await api_tokens.resolve(raw_token=raw, required_scope="deploy")resolve returns the token metadata only if the token exists, is not revoked or expired, and carries the required scope — otherwise None, with no reason disclosed. list_for_user, revoke, and revoke_all complete the lifecycle. When max_tokens_per_user is exceeded, create raises TokenLimitReached; the count-and-insert is race-safe on PostgreSQL and MySQL.
In an API route, pair it with the guard helper:
raw = bearer_token(request)
token = raw and await api_tokens.resolve(raw_token=raw, required_scope="deploy")
if token is None:
return JSONResponse({"error": "unauthorized"}, status_code=401)Rate limiting
Sign-in, sign-up, and password-reset are rate-limited per identifier (email and, when provided, IP) with hourly buckets stored in the database — no Redis required. Limits are settings (defaults: 10 sign-ins, 5 sign-ups, 3 resets per hour). One deliberate subtlety: a correct password is never blocked by the per-email bucket — only failed attempts count toward it — so an attacker hammering an inbox can't lock the real owner out of signing in.
Settings
Configure in pyxle.config.json (camelCase), override per environment with PYXLE_AUTH_* variables. Precedence: config > environment > default.
{
"plugins": [
"pyxle-db",
{
"name": "pyxle-auth",
"settings": { "cookieName": "myapp_session", "sessionTtlSeconds": 1209600 }
}
]
}| Config key | Env variable | Default |
|---|---|---|
argonTimeCost |
PYXLE_AUTH_ARGON_T |
3 |
argonMemoryKib |
PYXLE_AUTH_ARGON_M |
65536 |
argonParallelism |
PYXLE_AUTH_ARGON_P |
2 |
passwordMinLength |
PYXLE_AUTH_PW_MIN |
8 |
sessionTtlSeconds |
PYXLE_AUTH_SESSION_TTL |
2592000 (30 d) |
sessionAbsoluteMaxSeconds |
PYXLE_AUTH_SESSION_ABS_MAX |
7776000 (90 d) |
cookieName |
PYXLE_AUTH_COOKIE_NAME |
"pyxle_session" |
cookieSecure |
PYXLE_AUTH_COOKIE_SECURE |
true |
cookieSameSite |
PYXLE_AUTH_COOKIE_SAMESITE |
"Lax" |
cookieDomain |
PYXLE_AUTH_COOKIE_DOMAIN |
unset |
authPathPrefix |
PYXLE_AUTH_PATH_PREFIX |
"/auth" |
enableCredentialsApi |
PYXLE_AUTH_ENABLE_CREDENTIALS_API |
true |
passwordResetTtlSeconds |
PYXLE_AUTH_PASSWORD_RESET_TTL_SECONDS |
1800 (30 min) |
emailVerifyTtlSeconds |
PYXLE_AUTH_EMAIL_VERIFY_TTL_SECONDS |
86400 (24 h) |
rateLimitSignInPerHour |
PYXLE_AUTH_RL_SIGN_IN_PER_HOUR |
10 |
rateLimitSignUpPerHour |
PYXLE_AUTH_RL_SIGN_UP_PER_HOUR |
5 |
rateLimitUsernameCheckPerHour |
PYXLE_AUTH_RL_USERNAME_CHECK_PER_HOUR |
120 |
rateLimitPasswordResetPerHour |
PYXLE_AUTH_RATE_LIMIT_PASSWORD_RESET_PER_HOUR |
3 |
requireEmailVerified |
PYXLE_AUTH_REQUIRE_VERIFIED |
false |
identifier |
PYXLE_AUTH_IDENTIFIER |
email |
usernameMinLength |
PYXLE_AUTH_USERNAME_MIN |
3 |
usernameMaxLength |
PYXLE_AUTH_USERNAME_MAX |
30 |
usernamePattern |
— | ^[a-z0-9_-]+$ |
usernameReserved |
— | ~90 names |
strict |
PYXLE_AUTH_STRICT |
true |
Strict mode is the production posture and the default: it requires cookieSecure: true and enforces the argon2 strength floors, refusing to boot otherwise. Local HTTP development relaxes it through the environment — never in the committed config:
# .env for local dev only
PYXLE_AUTH_STRICT=false
PYXLE_AUTH_COOKIE_SECURE=falseBring your own database
pyxle-auth binds to the db.database plugin service, not to the pyxle-db package. Any plugin that registers an object satisfying pyxle_db.DatabaseLike can back it — an adapter over another engine, a test fake. The replacement must also translate unique-constraint violations into pyxle_db.IntegrityError (that's how duplicate sign-ups become AccountExists) and report a dialect.name the DDL helpers understand. The package's tests/test_database_contract.py runs the entire lifecycle against a deliberately foreign database object — it is both the executable specification and a template for writing an adapter.
Errors
All inherit from AuthError:
| Exception | Meaning |
|---|---|
InvalidCredentials |
Wrong email/password — message intentionally vague. |
AccountExists |
Duplicate sign-up. |
WeakPassword |
Below passwordMinLength (or absurdly long). |
RateLimited |
Bucket exhausted; carries retry_after_seconds. |
EmailNotVerified |
Sign-in blocked while requireEmailVerified is on. |
InvalidToken |
Reset/verify token stale, used, unknown, or wrong-purpose. |
TokenLimitReached |
API-token cap hit. |
RoleNotFound |
Granting an undefined role. |
OAuth sign-in (Google, GitHub, Discord)
Social sign-in ships in the pyxle_auth.oauth subpackage behind the [oauth]
extra (pip install 'pyxle-auth[oauth]'). Enable it with the oauth setting:
{
"plugins": [
"pyxle-db",
{
"name": "pyxle-auth",
"settings": {
"oauth": {
"providers": ["google", "github"],
"failureRedirect": "/login"
}
}
}
]
}Client credentials are read from the environment only — never put them in
pyxle.config.json:
PYXLE_AUTH_OAUTH_GOOGLE_CLIENT_ID=...
PYXLE_AUTH_OAUTH_GOOGLE_CLIENT_SECRET=...
PYXLE_AUTH_SECRET=... # signs the OAuth state cookie (required in strict mode)A sign-in link is just an anchor to the start endpoint:
<a href="/auth/oauth/google/start?next=/dashboard">Continue with Google</a>The middleware redirects to the provider, handles the callback, creates or
links the local account, sets the session cookie, and sends the user to next.
On failure it redirects to failureRedirect with ?oauth_error=<reason>
(state, denied, email_unverified, exchange, unknown_provider).
Security model — the callback is a GET carrying an attacker-influenceable
?code&state, so the defenses are deliberate:
- PKCE
S256is mandatory; the verifier lives only in the signed cookie. - A signed, single-use, HttpOnly
statecookie binds the flow to the browser; thestatethe provider echoes must equal the cookie's nonce (constant-time) — this is the login-CSRF defense. - An identity links to an existing account only when the provider says the email is verified — otherwise an attacker could pre-register the victim's address at the provider and hijack the account.
nextis same-origin path only (open-redirect guard).- Secrets come from the environment, are redacted in
repr, and never reach a log or the browser.
Set redirectBaseUrl when behind a reverse proxy / on a custom domain so the
redirect_uri matches what you registered with the provider.
JWT for API & mobile clients
For clients that send Authorization: Bearer instead of a cookie, enable JWT
([jwt] extra) with the jwt setting:
{ "name": "pyxle-auth", "settings": { "jwt": { "accessTtlSeconds": 900 } } }Two endpoints appear (sign with PYXLE_AUTH_SECRET / PYXLE_SECRET_KEY):
| Endpoint | Body | Returns |
|---|---|---|
POST /auth/token |
{ email, password } |
{ accessToken, refreshToken, expiresIn } |
POST /auth/token/refresh |
{ refreshToken } |
a rotated pair |
- Access token — a short-lived signed JWT (HS256), verified statelessly.
- Refresh token — a long-lived opaque string stored only as its hash. Refresh rotates: each use issues a new token and invalidates the old one. Replaying a rotated token revokes the whole family (theft detection).
CSRF: these endpoints authenticate from the request body (not a cookie), so they aren't CSRF-vulnerable — but the framework's CSRF middleware still guards POSTs. Add
/auth/tokenand/auth/token/refreshtocsrf.exemptPaths(camelCase) so non-browser clients can reach them. A snake-casedexempt_pathskey is silently ignored, so the token endpoints would keep rejecting requests.
Resolve a bearer token in a loader or API route with the guards, which try
JWT then PAT (and authenticate tries the session first):
from pyxle_auth import bearer_user, authenticate
user = await bearer_user(request) # JWT access token → PAT
user = await authenticate(request) # session → JWT → PATJWTService is also usable directly (auth.jwt service): issue_pair,
verify_access, refresh, revoke_family, revoke_all_for_user.
What pyxle-auth is not (yet)
Honest scope, so you can plan around it:
- No email delivery — by design, permanently. Bring your mailer.
- No multi-factor authentication (TOTP, WebAuthn) — not implemented yet.
The building blocks (sessions, TokenService, guards) compose underneath whatever you add on top; contributions are welcome.
See also
- pyxle-db — the database layer underneath, and the
DatabaseLikecontract. - Plugins guide — plugin ordering and service resolution.
- API routes — where cookie-setting endpoints live.
- Security guide — CSRF and the framework's request protections.