SerpentStack auth
Understand and customize authentication in this project. Use when: adding auth to routes, swapping auth providers (Clerk, Auth0, custom SSO), debugging auth issues, or understanding the UserInfo contract and get_current_user dependency.
git clone https://github.com/Benja-Pauls/SerpentStack
T=$(mktemp -d) && git clone --depth=1 https://github.com/Benja-Pauls/SerpentStack "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.skills/auth" ~/.claude/skills/benja-pauls-serpentstack-auth && rm -rf "$T"
.skills/auth/SKILL.mdAuth
SerpentStack ships with working local JWT authentication (register, login, token validation). This skill explains the auth architecture and how to swap providers.
How Auth Works (Built-in)
Architecture
POST /api/v1/auth/register → UserService.register() → bcrypt hash → DB → JWT POST /api/v1/auth/login → UserService.authenticate() → verify hash → JWT GET /api/v1/auth/me → get_current_user dependency → decode JWT → UserResponse Any protected route: @router.delete("/{id}") async def delete(user: UserInfo = Depends(get_current_user)): # user.user_id, user.email available here
Key Files
| File | Role |
|---|---|
| Auth routes + dependency |
| Registration, authentication, password hashing |
| User SQLAlchemy model (email, hashed_password) |
| Request/response schemas (register, login, token) |
| Optional global auth middleware (not enabled by default) |
The UserInfo
Contract
UserInfoAll protected routes receive a
UserInfo object via dependency injection:
class UserInfo(BaseModel): user_id: str email: str | None = None name: str | None = None raw_claims: dict[str, Any] = {}
This is the interface between auth and the rest of the app. When swapping providers, keep this shape — every route that uses
Depends(get_current_user) depends on it.
Protecting a Route
Add
Depends(get_current_user) to any route that requires authentication:
from app.routes.auth import UserInfo, get_current_user @router.post("") async def create_thing( payload: ThingCreate, user: UserInfo = Depends(get_current_user), # ← requires valid JWT db: AsyncSession = Depends(get_db), service: ThingService = Depends(get_thing_service), ) -> ThingResponse: thing = await service.create(payload, owner_id=user.user_id) await db.commit() return ThingResponse.model_validate(thing)
For optional auth (authenticated if token present, anonymous otherwise):
from app.routes.auth import get_optional_user @router.get("") async def list_things( user: UserInfo | None = Depends(get_optional_user), ) -> list[ThingResponse]: # user is None if no token, UserInfo if authenticated ...
Swapping to an External Provider
To replace the built-in JWT auth with Clerk, Auth0, or another provider:
Step 1: Replace get_current_user
in routes/auth.py
get_current_userroutes/auth.pyThe only function you need to change is
get_current_user. Replace JWT decode with your provider's token validation:
For Clerk — see Clerk FastAPI guide:
from jwt import PyJWKClient jwks_client = PyJWKClient("https://your-clerk-domain/.well-known/jwks.json") async def get_current_user( credentials: HTTPAuthorizationCredentials | None = Security(bearer_scheme), ) -> UserInfo: if credentials is None: raise HTTPException(status_code=401, detail="Not authenticated") token = credentials.credentials signing_key = jwks_client.get_signing_key_from_jwt(token) payload = jwt.decode(token, signing_key.key, algorithms=["RS256"]) return UserInfo( user_id=payload["sub"], email=payload.get("email"), name=payload.get("name"), raw_claims=payload, )
For Auth0 — see Auth0 FastAPI guide:
jwks_client = PyJWKClient("https://your-tenant.auth0.com/.well-known/jwks.json") async def get_current_user(...) -> UserInfo: # Same pattern, add audience validation: payload = jwt.decode( token, signing_key.key, algorithms=["RS256"], audience="your-api-audience", issuer="https://your-tenant.auth0.com/", ) return UserInfo(user_id=payload["sub"], email=payload.get("email"), ...)
Step 2: Remove unused files (optional)
If you no longer need local registration/login:
- Remove
backend/app/services/user.py - Remove
(and its import inbackend/app/models/user.py
)models/__init__.py - Remove
backend/app/schemas/user.py - Remove the
and/register
routes from/loginroutes/auth.py - Remove
frompasslib[bcrypt]pyproject.toml
Step 3: Update environment variables
Add your provider's config to
.env:
# For Clerk: CLERK_JWKS_URL=https://your-clerk-domain/.well-known/jwks.json # For Auth0: AUTH0_DOMAIN=your-tenant.auth0.com AUTH0_AUDIENCE=your-api-audience
What stays the same
shape — all routes keep workingUserInfo
pattern — no route changes neededDepends(get_current_user)
— works with any providerget_optional_user- Frontend token storage pattern — still sends
Authorization: Bearer <token>
Testing Auth
# Register a user curl -X POST http://localhost:8000/api/v1/auth/register \ -H "Content-Type: application/json" \ -d '{"email": "test@example.com", "password": "testpass123"}' # Login curl -X POST http://localhost:8000/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email": "test@example.com", "password": "testpass123"}' # Use the token TOKEN="<access_token from login response>" curl http://localhost:8000/api/v1/auth/me \ -H "Authorization: Bearer $TOKEN" # Protected route (delete item) curl -X DELETE http://localhost:8000/api/v1/items/<item-id> \ -H "Authorization: Bearer $TOKEN"
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| 401 on every request | Missing or malformed header | Check header format, ensure token isn't expired |
| 422 on register | Password too short or invalid email | Password must be ≥8 chars, email must be valid |
| 409 on register | Email already taken | Use a different email or login instead |
in logs | Token signed with wrong key or expired | Check matches between token creation and validation |