Standards-first authentication for hayate: sessions, OAuth 2.1, passkeys, API keys, and Cloudflare D1
Project description
hayate-auth
Hayate ecosystem: Start here · Production golden app · Tested compatibility
Standards-first authentication for hayate — a mountable, better-auth-style auth handler built on the WHATWG Request/Response model.
Status: alpha (0.9.x). Email+password, sessions, CSRF, email verification, password reset, OAuth 2.1 + PKCE (Google / GitHub), TOTP two-factor, API keys, an OAuth 2.1 authorization server (AS mode: RFC 8414 metadata, RFC 7591 dynamic registration, MCP Client ID Metadata Documents, PKCE-only code + refresh grants), magic links, and passkeys (WebAuthn L3,
[passkey]extra) are implemented and attack-regression-tested; agenerateCLI, a plugin API, and a Cloudflare D1 adapter ship too. Not yet security-audited — see SECURITY.md. The internal design memo (Japanese) lives in DESIGN.md; release history is in CHANGELOG.md.
import os
from hayate import Hayate
from hayate_auth import Auth
from hayate_auth.adapters.sqlite import SQLiteAdapter
adapter = SQLiteAdapter("app.db")
adapter.create_tables()
auth = Auth(secret=os.environ["AUTH_SECRET"], adapter=adapter)
app = Hayate()
auth.register(app) # serves /api/auth/* (sign-up, sign-in, session, ...)
@app.get("/me", auth.require_session())
async def me(c):
return c.json(c.get("user"))
The same file runs under any ASGI server and on Cloudflare Python Workers — see examples/todo.
Endpoints
Method / path (under /api/auth) |
Purpose |
|---|---|
POST /sign-up/email |
Register with email + password, start a session |
POST /sign-in/email |
Verify credentials, start a session |
GET /get-session |
Current {user, session} (or nulls) |
POST /sign-out |
Revoke the session server-side |
POST /forget-password → /reset-password |
Reset flow via a one-shot hashed token |
GET /verify-email |
Confirm an email with a one-shot token |
POST /sign-in/social → GET /callback/:provider |
OAuth 2.1 + PKCE (Google / GitHub) |
POST /two-factor/enable · /verify · /disable |
TOTP (RFC 6238) enrollment |
POST /sign-in/two-factor |
Second step when 2FA is on |
POST /api-key/create · /verify · /delete · GET /api-key/list |
API keys (hashed, scoped, expiring) |
GET /oauth2/authorize · POST /oauth2/consent · /oauth2/token · /oauth2/register |
AS mode: OAuth 2.1 authorization server |
POST /sign-in/magic-link → GET /magic-link/verify |
Magic links (plugin) |
POST /passkey/generate-register-options · /verify-registration · /generate-authenticate-options · /verify-authentication · GET /passkey/list-user-passkeys · POST /passkey/delete-passkey |
Passkeys (WebAuthn L3, [passkey] extra) |
Magic links ship as the first AuthPlugin — plugins add routes with the
same handler signature the built-ins use:
from hayate_auth.plugins import magic_link
auth = Auth(
secret=..., adapter=adapter,
plugins=[magic_link(send=deliver_link_email)], # async (email, token) -> None
)
Passkeys need the extra (pip install hayate-auth[passkey], pulls
py_webauthn) and a relying-party config:
from hayate_auth import PasskeyConfig
auth = Auth(
secret=..., adapter=adapter,
passkey=PasskeyConfig(rp_id="example.com", rp_name="My App",
origin="https://example.com"),
)
AS mode: be the OAuth authorization server
Pass an AuthorizationServer config and your app issues OAuth 2.1 tokens —
authorization-code + PKCE (S256 only), refresh rotation with reuse detection,
RFC 8414 metadata at /.well-known/oauth-authorization-server, and open
RFC 7591 dynamic client registration (what MCP clients expect). Tokens are
opaque and stored hashed; login and consent pages stay yours (login_url /
consent_url), the consent decision is one JSON POST.
from hayate_auth import Auth, AuthorizationServer
auth = Auth(
secret=os.environ["AUTH_SECRET"],
adapter=adapter,
authorization_server=AuthorizationServer(
issuer="https://app.example.com",
login_url="/login",
consent_url="/consent",
scopes_supported=("mcp",),
resource="https://app.example.com/mcp",
),
)
Paired with hayate-mcp's resource server, that is an MCP server and its authorization server in one app — the flow MCP Inspector and Claude Code drive end to end (examples/mcp-oauth):
from hayate_mcp import Authorization, McpMount
McpMount(server, authorization=Authorization(
resource="https://app.example.com/mcp",
authorization_servers=["https://app.example.com"],
verify_token=auth.oauth_token_verifier(resource="https://app.example.com/mcp"),
)).register(app)
MCP 2025-11-25 recommends Client ID Metadata Documents before DCR. Enable them with an injected fetcher so your deployment controls DNS and egress:
from hayate_auth import ClientIdMetadataDocuments
authorization_server = AuthorizationServer(
# issuer/login_url/consent_url/scopes_supported/resource as above
client_id_metadata_documents=ClientIdMetadataDocuments(
fetch_client_metadata, # async URL -> hayate.Response; must reject redirects
allow_url=outbound_client_policy,
),
)
hayate-auth validates HTTPS URL-form client IDs, exact client_id, same-origin
or loopback redirects, public-client metadata, JSON content type, and a 5 KiB
body limit. The fetcher remains responsible for DNS-level SSRF protection.
API keys are the lighter-weight bridge to the same resource server —
verify_token=auth.verify_api_key protects an MCP server with a static key
instead of the full OAuth dance.
REST and generated OpenAPI share the same authorization declaration:
from hayate_openapi import OpenApi
@app.get("/documents", auth.require_oauth_token(
"documents:read", resource="https://app.example.com/mcp"
))
async def documents(c):
return c.json({"subject": c.get("principal")["subject"]})
OpenApi(
app,
title="API",
version="1",
security_schemes=auth.openapi_security_schemes(),
)
With TOTP enabled, /sign-in/email returns {"two_factor_required": true} plus
a short-lived signed challenge cookie instead of a session; the client then
posts the authenticator code to /sign-in/two-factor to get the session — so a
stolen password alone never signs in.
Email delivery is your callback (send_reset_password / send_verification_email);
the core mints and verifies tokens but never builds URLs or sends mail. Generate
migration DDL with python -m hayate_auth generate --dialect sqlite|postgres|d1.
OAuth providers are injected; the token exchange runs over hayate-fetch, so it works on ASGI and Workers alike:
from hayate_auth import Auth, google, github
auth = Auth(
secret=os.environ["AUTH_SECRET"],
adapter=adapter,
providers=[
google(client_id=..., client_secret=...),
github(client_id=..., client_secret=...),
],
)
Why
- Python has no equivalent of better-auth: a framework-agnostic, self-hosted, schema-owning auth library. django-allauth is Django-only; fastapi-users is in maintenance mode.
- better-auth works on every JS framework because its core is a single
fetch(Request) -> Responsehandler. hayate is the only Python framework whose user-facing surface is WHATWG Request/Response — so that architecture finally maps 1:1 to Python. - Zero-dependency core (its only dependency is hayate, itself zero-dependency). Databases, KDFs, and email are injected protocols.
Security posture
- Passwords: scrypt at OWASP parameters (N=2^17, r=8, p=1) on every runtime, PBKDF2-HMAC-SHA256 (600k) fallback; PHC-style strings make the backends mutually verifiable. Length-only policy per NIST SP 800-63B.
- Sessions: opaque 256-bit tokens, only their SHA-256 stored;
__Host--prefixed HttpOnly SameSite=Lax cookies on HTTPS. - CSRF: SameSite + Origin (RFC 6454) + Fetch Metadata — no token embedding.
- Sign-in failures are uniform in body and KDF timing (enumeration defense).
- Coverage ledger: docs/asvs.md (OWASP ASVS V6/V7, ratcheted).
- You must rate-limit
/api/auth/*(hayate middleware or your infrastructure): brute-force throttling is deliberately out of core. - Authorization-server adapters must implement atomic
update_many()and return the affected-row count. This prevents concurrent authorization-code or refresh-token redemption from minting multiple token families. Durable compromise markers and guarded post-insert finalization also prevent replay detection during token creation from leaving a live family.
License
MIT
Project details
Release history Release notifications | RSS feed
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 hayate_auth-0.9.1.tar.gz.
File metadata
- Download URL: hayate_auth-0.9.1.tar.gz
- Upload date:
- Size: 43.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c48bb7449663a04f278d6fddb052b25d47cacbccc53b5534c97330d72e86b5d4
|
|
| MD5 |
9168325853875bc442f3c9b0953bfd10
|
|
| BLAKE2b-256 |
15ae433dffe8e084e56e244ea3c7fa17a055780b5b53c94877107fefe6663579
|
Provenance
The following attestation bundles were made for hayate_auth-0.9.1.tar.gz:
Publisher:
release.yml on hayatepy/hayate-auth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hayate_auth-0.9.1.tar.gz -
Subject digest:
c48bb7449663a04f278d6fddb052b25d47cacbccc53b5534c97330d72e86b5d4 - Sigstore transparency entry: 2256175384
- Sigstore integration time:
-
Permalink:
hayatepy/hayate-auth@b8486cf40cfa227b44062ee41bddb4a6b74132fa -
Branch / Tag:
refs/tags/v0.9.1 - Owner: https://github.com/hayatepy
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b8486cf40cfa227b44062ee41bddb4a6b74132fa -
Trigger Event:
push
-
Statement type:
File details
Details for the file hayate_auth-0.9.1-py3-none-any.whl.
File metadata
- Download URL: hayate_auth-0.9.1-py3-none-any.whl
- Upload date:
- Size: 58.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8c687172afb367d58d795008ffe0e661cc5aac2a12c1691c0637be0eb15a9925
|
|
| MD5 |
977230f657dd77808ad688382a84c611
|
|
| BLAKE2b-256 |
0f55eee563bddea94115747c632b273017e1caa7db8922fa90824799de9d23a9
|
Provenance
The following attestation bundles were made for hayate_auth-0.9.1-py3-none-any.whl:
Publisher:
release.yml on hayatepy/hayate-auth
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hayate_auth-0.9.1-py3-none-any.whl -
Subject digest:
8c687172afb367d58d795008ffe0e661cc5aac2a12c1691c0637be0eb15a9925 - Sigstore transparency entry: 2256175392
- Sigstore integration time:
-
Permalink:
hayatepy/hayate-auth@b8486cf40cfa227b44062ee41bddb4a6b74132fa -
Branch / Tag:
refs/tags/v0.9.1 - Owner: https://github.com/hayatepy
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b8486cf40cfa227b44062ee41bddb4a6b74132fa -
Trigger Event:
push
-
Statement type: