guest-auth
Not a replacement for real authentication. A static-allowlist invite-token gate for pre-production demos and invite-only previews. Pure-ASGI middleware that plugs into any Starlette / FastAPI app in ~5 lines.
Give a tester a link like https://your-app.example.com/?token=tok_abc123; the middleware validates the token against an allowlist you own, exchanges it for an httpOnly cookie, and attaches an identity (token + human-readable recipient label) to the request via a ContextVar that reaches sync endpoints in the threadpool as well.
The library was extracted from Pitchcraft and is consumed there in production; Rulebook and JobScout are scheduled to adopt it.
Adopting this in a new app? See docs/integration.md for the DI pattern, the app_state.py template, gotchas (init order, pure-ASGI vs BaseHTTPMiddleware, ContextVar propagation), and the constructor reference.
What this is NOT
Naming a library *-auth invites expectations it doesn't meet. To be explicit:
- No password handling, no MFA, no OAuth / OIDC, no account lifecycle. The credential is an opaque token you generate and hand to a tester.
- No token rotation, expiry, revocation-list, or signed cookies. The cookie is httpOnly + Secure + SameSite=Lax with a 30-day convenience lifetime; revoking access means removing the token from the allowlist and redeploying.
- No rate limiting. Compose one separately (e.g.
llm-guardrailsships an IP rate limiter). - No policy engine. The identity can carry
role/scopesclaims (see below), but guest-auth never interprets or enforces them — no capability vocabulary, no role→permission bundles, norequire_xdecorator. That's authz over the claims and it lives in your app. - Not audited for adversarial threat models. This is a gate to keep pre-production URLs off the open web and attribute per-tester activity, not a substitute for real identity infrastructure. If you're gating production PII or payment flows, use something else.
The value the library provides — a well-scoped ASGI middleware that publishes a per-request identity ContextVar that reaches sync endpoints — is genuinely useful and hard to get right (the "pure-ASGI vs BaseHTTPMiddleware" trap is subtle). Everything above is deferred, not planned.
Install
pip install guest-auth
Requires Python 3.11+. The only runtime dependency is starlette, which any ASGI host already has.
Quick example
from dataclasses import dataclass, field
from fastapi import FastAPI
from guest_auth import InviteAuthMiddleware, get_current_guest
@dataclass
class Settings:
demo_mode: bool = True
invite_tokens: dict = field(
default_factory=lambda: {"tok_abc123": "Jane Tester"}
)
settings = Settings()
app = FastAPI()
@app.get("/")
def home():
guest = get_current_guest()
return {"welcome": guest.recipient if guest else "anonymous"}
app.add_middleware(
InviteAuthMiddleware,
config=settings,
# Optional — pre-rendered HTML for the 401 / welcome page.
# Omit to use the built-in "This site is currently invite-only." body.
welcome_html="<h1>Preview build</h1><p>Ask jane@example.com for a link.</p>",
)
Now:
GET /?token=tok_abc123→ 302 to/, setsguest_sessioncookie.GET /with the cookie → returns{"welcome": "Jane Tester"}.GET /without a cookie → 401 with the welcome page.- Flip
settings.demo_mode = False→ gate becomes a complete pass-through with no restart.
Core concepts
GuestAuthConfig (Protocol)
The middleware takes a config object that satisfies:
class GuestAuthConfig(Protocol):
demo_mode: bool
invite_tokens: Mapping[str, str] # token → recipient label
Both attributes are read at request time, so mutating a live config instance (a pydantic BaseSettings, a dataclass, whatever) takes effect on the next request without rebuilding the middleware.
GuestIdentity + get_current_guest()
On a successful cookie match, the middleware sets a request-scoped ContextVar with GuestIdentity(token=..., recipient=..., role=..., scopes=...). Anywhere downstream — sync or async, including code paths in Starlette's threadpool — get_current_guest() returns it or None.
Because the middleware is pure-ASGI (not BaseHTTPMiddleware), the ContextVar survives into the threadpool that runs def (sync) endpoints. See the integration doc for why this matters.
Claims: role and scopes
Optionally, the identity carries authorization claims. guest-auth defines their structure and never their meaning — any string is a role, any strings are scopes:
from guest_auth import GuestClaims
def resolve_claims(token: str) -> GuestClaims:
return GuestClaims(role="level5", scopes=("rules", "faq")) # your store
app.add_middleware(
InviteAuthMiddleware, config=settings, claims_resolver=resolve_claims
)
The resolver may be sync or async; a sync one runs in Starlette's threadpool, so a resolver that reads a bucket doesn't stall the event loop. Caching is the resolver's job.
Two contracts to know: resolution failure is soft (log + empty claims, never
a 401), and consequently role=None means unresolved, not unprivileged — map
it to your own floor explicitly. Full detail in the integration doc.
PathScopedContextVarMiddleware (bonus)
An adjacent generic that ships in the same package: match a regex against scope["path"], publish an extracted value on a caller-supplied ContextVar for the duration of the request. Same pure-ASGI rationale as the auth middleware. Use for /api/things/{id}/… style path-scoped ContextVars (session IDs, tenant IDs, whatever).
Minting invite tokens
Installing the package also installs guest-auth-tokens, a small offline helper
for the chore every app behind this middleware repeats: turn a list of names into
tokens and links you can send people. Stdlib only — it adds no dependency.
Write one recipient per line (an inline # adds a private note that stays local):
Alice
Mike # met at worlds
Bob Smith
Then:
guest-auth-tokens gen --names secrets/guests.txt --out secrets/tokens.json --links secrets/links.md --base-url https://your-app.example.com
That writes a {token: label} JSON — feed it to config.invite_tokens however
your app prefers — and a markdown file of shareable invite links.
Re-running is merge-preserving: a name that already has a token keeps it, so links you've already sent never break. A name you remove is reported rather than revoked — its token stays valid until you delete it by hand, because revoking someone's access shouldn't be a side effect of regenerating a file.
Both output files contain working credentials. The tool checks whether they're
covered by a .gitignore and warns loudly if not.
Note what this deliberately isn't: there's no live allowlist management, no storage backend, and no cloud dependency. Where your app keeps its allowlist and how it refreshes it stays yours.
Development
git clone https://github.com/ecoop/guest-auth
cd guest-auth
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
ruff check src tests
CI runs on Python 3.11 and 3.12 via GitHub Actions.
Versioning
Currently v0.2.0. Semver from v1.0.0 onward; anything before is "shipped but pre-stable API — expect breaking changes."
Contributing
Issues and pull requests welcome. For substantive changes, open an issue first — this library has a deliberately small surface and staying small is a feature.
License
MIT. See LICENSE.
Last updated: 2026-09-05
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file guest_auth-0.2.0.tar.gz.
File metadata
- Download URL: guest_auth-0.2.0.tar.gz
- Upload date:
- Size: 30.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a3017ef908093ce23a96ed9b686085f2db89af951a3d248a56f1e247727f1332
|
|
| MD5 |
4e0e45a45e1da2e5d30803e8d4fb65da
|
|
| BLAKE2b-256 |
652f2a05108221147fa609b73e9862360c382186aa389b7bfa3298cf2f248b4f
|
Provenance
The following attestation bundles were made for guest_auth-0.2.0.tar.gz:
Publisher:
release.yml on ecoop/guest-auth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
guest_auth-0.2.0.tar.gz -
Subject digest:
a3017ef908093ce23a96ed9b686085f2db89af951a3d248a56f1e247727f1332 - Sigstore transparency entry: 2731666634
- Sigstore integration time:
-
Permalink:
ecoop/guest-auth@7c7281b033f842abb206e594d7af612c4ca542d6 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/ecoop
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7c7281b033f842abb206e594d7af612c4ca542d6 -
Trigger Event:
push
-
Statement type:
File details
Details for the file guest_auth-0.2.0-py3-none-any.whl.
File metadata
- Download URL: guest_auth-0.2.0-py3-none-any.whl
- Upload date:
- Size: 22.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
583bcb6c80872f6a9d1704c610aa209032bd3676e37039b991930ab86cd164d1
|
|
| MD5 |
ecb7ba30c49b477243b437a6ece16c8f
|
|
| BLAKE2b-256 |
20d8bcea6ac2ba41d86fda281b72450b43e95af579b64dac83183af2d5a57f5f
|
Provenance
The following attestation bundles were made for guest_auth-0.2.0-py3-none-any.whl:
Publisher:
release.yml on ecoop/guest-auth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
guest_auth-0.2.0-py3-none-any.whl -
Subject digest:
583bcb6c80872f6a9d1704c610aa209032bd3676e37039b991930ab86cd164d1 - Sigstore transparency entry: 2731668061
- Sigstore integration time:
-
Permalink:
ecoop/guest-auth@7c7281b033f842abb206e594d7af612c4ca542d6 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/ecoop
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7c7281b033f842abb206e594d7af612c4ca542d6 -
Trigger Event:
push
-
Statement type: