Documentation Index
Fetch the complete documentation index at: https://docs.zyeta.io/llms.txt
Use this file to discover all available pages before exploring further.
Definable uses Stytch as its authentication provider, providing secure user authentication, session management, and invitation flows. This page explains how Stytch is integrated into the backend and how it works with the Definable authentication system.
Overview
Stytch provides the following capabilities in Definable:
- User Authentication: Password-based and magic link authentication
- Session Management: JWT-based session tokens validated via JWKS
- User Invitations: Email-based invitation system for organization onboarding
- Webhook Events: Real-time user lifecycle event processing
- External User Tracking: Links Stytch user IDs to internal user records
Architecture
JWKS-Based JWT Validation
Definable uses Stytch’s JWKS (JSON Web Key Set) endpoint to validate session tokens locally without making API calls to Stytch for every request.
How JWKS Validation Works
- Client receives JWT from Stytch after successful login
- Client sends JWT in
Authorization: Bearer <token> header
- JWTBearer middleware extracts the token
- JWKS client fetches public keys from Stytch’s JWKS endpoint (cached)
- Token is cryptographically verified using the public key
- User lookup using the
sub claim (stytch_id) from decoded token
- User context returned to the request handler
Implementation
The JWKS verification is implemented in src/libs/stytch/v1/jkws.py:
class StytchLocalVerifier:
def __init__(self, project_id: str, environment: Optional[str] = "test"):
self.project_id = project_id
# JWKS URL based on environment
if environment == "test":
self.jwks_url = f"https://test.stytch.com/v1/sessions/jwks/{project_id}"
else:
self.jwks_url = f"https://api.stytch.com/v1/sessions/jwks/{project_id}"
# Initialize JWKS client with caching
self.jwks_client = PyJWKClient(
self.jwks_url,
cache_keys=True,
max_cached_keys=100,
cache_jwk_set=True,
lifespan=600, # Cache for 10 minutes
)
def verify_session_token(self, session_token: str) -> Dict[str, Any]:
# Get signing key from JWKS
signing_key = self.jwks_client.get_signing_key_from_jwt(session_token)
# Verify token using RS256 algorithm
decoded_token = jwt.decode(
session_token,
signing_key.key,
algorithms=["RS256"],
audience=self.project_id,
options={
"verify_signature": True,
"verify_exp": True,
"verify_iat": True,
"verify_aud": True,
"require": ["exp", "iat", "aud", "sub", "iss"],
},
)
return decoded_token
JWTBearer Dependency
The JWTBearer class in src/dependencies/security.py uses Stytch JWKS validation:
class JWTBearer(HTTPBearer):
async def __call__(
self,
request: Request = None,
websocket: WebSocket = None,
session: AsyncSession = Depends(get_db),
) -> Any:
if request:
credentials = await super().__call__(request)
if not credentials or credentials.scheme != "Bearer":
raise HTTPException(status_code=403, detail="Invalid authorization")
# Verify JWT using Stytch JWKS
response = await stytch_base.authenticate_user_with_jkws(credentials.credentials)
if response.success:
# Look up user by stytch_id
user_query = select(UserModel).where(UserModel.stytch_id == response.data["sub"])
user_result = await session.execute(user_query)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=403, detail="User not found")
return {"stytch_user_id": response.data["sub"], "id": str(user.id)}
else:
raise HTTPException(status_code=403, detail="Access denied")
Key Features
- RS256 Algorithm: Stytch uses asymmetric encryption (public/private key pairs)
- Cached Keys: Public keys are cached for 10 minutes to reduce latency
- Audience Validation: Ensures token was issued for your Stytch project
- Claims Verification: Validates expiration, issued-at, audience, subject, and issuer
- WebSocket Support: Same validation works for WebSocket connections using query parameters
Webhook Integration with Svix
Stytch sends webhook events to Definable when user lifecycle events occur (user creation, deletion, etc.). These webhooks are secured using Svix signature verification.
Webhook Flow
- User event occurs in Stytch (e.g., user signs up)
- Stytch sends webhook to Definable’s
/api/auth endpoint
- Svix headers included:
svix-id, svix-timestamp, svix-signature
- Signature verified using webhook secret
- Event processed based on action type
Svix Signature Verification
Implemented in src/utils/verify_wh.py:
def verify_svix_signature(svix_id, svix_timestamp, body, remote_signature):
# Create the signed content string
signed_content = f"{svix_id}.{svix_timestamp}.{body}"
# Extract and decode the secret (format: "whsec_xxxxx")
secret_part = settings.stytch_webhook_secret.split("_")[1]
secret_bytes = base64.b64decode(secret_part)
# Create HMAC signature
signature = hmac.new(
key=secret_bytes,
msg=signed_content.encode("utf-8"),
digestmod=hashlib.sha256
)
encoded_signature = base64.b64encode(signature.digest()).decode("utf-8")
# Compare with provided signature (format: "v1,signature")
return encoded_signature == remote_signature.split(",")[1]
Webhook Handler
The webhook handler in src/services/auth/service.py processes user events:
async def post(self, request: Request, db: AsyncSession = Depends(get_db)) -> JSONResponse:
# Extract Svix headers
signature = request.headers["svix-signature"]
svix_id = request.headers["svix-id"]
svix_timestamp = request.headers["svix-timestamp"]
body = await request.body()
# Verify webhook signature
status = verify_svix_signature(svix_id, svix_timestamp, body.decode("utf-8"), signature)
if not status:
raise HTTPException(status_code=400, detail="Invalid signature")
data = json.loads(body.decode("utf-8"))
if data["action"] == "CREATE":
user = data["user"]
# Skip temporary users (test signups)
if user["untrusted_metadata"].get("temp"):
return JSONResponse(content={"message": "User created from temp"})
# Check if invitation-based or regular user
metadata = user.get("untrusted_metadata", {})
if metadata.get("type") == "invitation":
return await self._process_invitation_user(user, metadata, db)
else:
return await self._process_regular_user(user, db)
User Registration Flows
Definable supports two user registration flows via Stytch:
1. Regular User Registration
Flow:
- User signs up via Stytch (password or magic link)
- Stytch webhook triggers with
action: "CREATE"
- UserModel created with
stytch_id from webhook
- Default organization created and user assigned “owner” role
- Default auth token generated (365-day JWT)
- Starter subscription with initial credits created
Implementation:
async def _process_regular_user(self, user: dict, db: AsyncSession) -> JSONResponse:
db_user = await self._create_new_user(
StytchUser(
email=user["emails"][0]["email"],
stytch_id=user["user_id"],
first_name=user["name"]["first_name"],
last_name=user["name"]["last_name"],
metadata=user.get("untrusted_metadata", {}),
),
db,
)
if db_user:
# Link Stytch user to internal user ID
await stytch_base.update_user(user["user_id"], str(db_user.id))
return JSONResponse(content={"message": "User created successfully"})
2. Invitation-Based Registration
Flow:
- Admin invites user via email
- Pre-created UserModel with
stytch_id=None, status=“invited”
- Invitation email sent via Stytch with
trusted_metadata.external_user_id
- User clicks invitation link and signs up via Stytch
- Stytch webhook triggers with
type: "invitation" in untrusted_metadata
- UserModel updated with
stytch_id from Stytch
- OrganizationMember status changed from “invited” to “active”
- Invitation status updated to “ACCEPTED”
Implementation:
async def _process_invitation_user(self, user: dict, metadata: dict, db: AsyncSession) -> JSONResponse:
# Extract external_user_id from trusted metadata
trusted_metadata = user.get("trusted_metadata", {})
external_user_id = trusted_metadata.get("external_user_id")
if not external_user_id:
return JSONResponse(content={"message": "Invalid invitation data"})
# Find pre-created user
user_id = UUID(external_user_id)
db_user = await db.get(UserModel, user_id)
# Find invited organization member
member_query = select(OrganizationMemberModel).where(
and_(
OrganizationMemberModel.user_id == user_id,
OrganizationMemberModel.status == "invited"
)
)
member_result = await db.execute(member_query)
org_member = member_result.scalar_one_or_none()
# Activate user with Stytch ID
db_user.stytch_id = user["user_id"]
# Update member status to active
org_member.status = "active"
# Mark invitation as accepted
invitation = await db.get(InvitationModel, org_member.invite_id)
invitation.status = InvitationStatus.ACCEPTED
# Link Stytch user to internal user
await stytch_base.update_user(user["user_id"], str(db_user.id))
await db.commit()
Sending Invitations
When an admin invites a user, Stytch is used to send the invitation email:
# From src/libs/stytch/v1/base.py
async def invite_user_for_organization(
self,
email: str,
first_name: str | None = None,
last_name: str | None = None,
external_user_id: str | None = None,
) -> LibResponse[InviteResponse] | LibResponse[None]:
name = Name(first_name=first_name, last_name=last_name)
# Trusted metadata (secure, not editable by user)
trusted_metadata = {
"external_user_id": external_user_id,
"is_invited": True
}
# Untrusted metadata (for webhook processing)
untrusted_metadata = {"type": "invitation"}
response = await self.client.magic_links.email.invite_async(
email,
name=name,
trusted_metadata=trusted_metadata,
untrusted_metadata=untrusted_metadata
)
return LibResponse.success_response(response)
Password Authentication
Definable supports password-based authentication via Stytch:
Sign Up with Password
async def post_test_signup(self, test_signup: TestSignup, db: AsyncSession) -> JSONResponse:
# Create user in Stytch with password
create_user_response = await stytch_base.create_user_with_password(
test_signup.first_name,
test_signup.last_name,
test_signup.email,
test_signup.password
)
if create_user_response.success is False:
raise HTTPException(status_code=500, detail=create_user_response.model_dump_json())
# Create internal user record
db_user = await self._create_new_user(
StytchUser(
email=create_user_response.data.user.emails[0].email,
stytch_id=create_user_response.data.user_id,
first_name=create_user_response.data.user.name.first_name,
last_name=create_user_response.data.user.name.last_name,
metadata={},
),
db,
)
Login with Password
async def post_test_login(self, test_login: TestLogin, db: AsyncSession) -> TestResponse:
# Authenticate via Stytch
authenticate_user_response = await stytch_base.authenticate_user_with_password(
test_login.email,
test_login.password
)
if authenticate_user_response.success is False:
raise HTTPException(status_code=500, detail=authenticate_user_response.model_dump_json())
# Return session JWT
return TestResponse(token=authenticate_user_response.data.session_jwt)
UserModel and stytch_id
The UserModel stores the Stytch user ID to link internal users with Stytch:
class UserModel(CRUD):
__tablename__ = "users"
# Stytch user identifier (nullable for invited users not yet activated)
stytch_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
unique=True,
index=True
)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
password: Mapped[str] = mapped_column(String(64), nullable=True) # Deprecated, use Stytch
first_name: Mapped[str] = mapped_column(String(50), nullable=True)
last_name: Mapped[str] = mapped_column(String(50), nullable=True)
_metadata: Mapped[dict] = mapped_column("metadata", JSONB, nullable=True)
Key Points:
stytch_id is nullable to support invited users who haven’t signed up yet
stytch_id is unique and indexed for fast lookups during authentication
password field is deprecated - passwords are managed by Stytch
- When invitation is accepted,
stytch_id is populated from webhook
Configuration
Stytch integration requires the following environment variables:
# Stytch Configuration
STYTCH_PROJECT_ID=project-test-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
STYTCH_SECRET=secret-test-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
STYTCH_ENVIRONMENT=test # or "live" for production
STYTCH_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Environment Settings
Defined in config/settings.py:
class Settings(BaseSettings):
# Stytch authentication
stytch_project_id: str
stytch_secret: str
stytch_environment: str = "test"
stytch_webhook_secret: str
JWKS Endpoints
- Test Environment:
https://test.stytch.com/v1/sessions/jwks/{project_id}
- Live Environment:
https://api.stytch.com/v1/sessions/jwks/{project_id}
Security Considerations
Token Validation
- Algorithm: RS256 (asymmetric cryptography)
- Signature Verification: Tokens verified using Stytch’s public keys
- Claim Validation: exp, iat, aud, sub, iss claims are required
- Expiration: Tokens expire based on Stytch session duration (default: 1440 minutes / 24 hours)
Webhook Security
- Svix Signatures: All webhooks must have valid Svix signatures
- HMAC-SHA256: Signatures use HMAC with SHA256 hashing
- Timestamp Verification: Prevents replay attacks
- Secret Management: Webhook secret stored securely in environment variables
- Trusted Metadata: Stored securely by Stytch, not editable by users
- Used for:
external_user_id, is_invited flag
- Untrusted Metadata: Can be set by clients
- Used for:
type: "invitation", temp: true for test users
- Never trust for security decisions
Testing
Test Endpoints
Definable provides test endpoints for local development:
POST /api/auth/test_signup - Create user with password
POST /api/auth/test_login - Authenticate with password
POST /api/auth/verify_api_key - Verify API key validity
Note: Test signups include untrusted_metadata: {"temp": true} to prevent webhook processing during development.
Example Test Flow
# 1. Sign up
response = requests.post(
f"{API_URL}/api/auth/test_signup",
json={
"email": "test@example.com",
"password": "SecurePassword123!",
"first_name": "John",
"last_name": "Doe"
}
)
# 2. Login
response = requests.post(
f"{API_URL}/api/auth/test_login",
json={
"email": "test@example.com",
"password": "SecurePassword123!"
}
)
token = response.json()["token"]
# 3. Use token
response = requests.get(
f"{API_URL}/api/some-endpoint",
headers={"Authorization": f"Bearer {token}"}
)
Implementation Files
Core Files
src/dependencies/security.py: JWTBearer class with JWKS validation
src/libs/stytch/v1/jkws.py: JWKS client and token verification
src/libs/stytch/v1/base.py: Stytch API wrapper
src/services/auth/service.py: Webhook handler and user creation
src/utils/verify_wh.py: Svix signature verification
src/models/auth_model.py: UserModel with stytch_id field
Configuration Files
config/settings.py: Stytch environment variables
.env: Stytch credentials and configuration
Troubleshooting
Invalid Token Errors
Symptoms:
- Error: “Invalid token signature”
- Error: “Token has expired”
Solutions:
- Verify token is from correct Stytch environment (test vs live)
- Check JWKS endpoint is accessible
- Ensure
STYTCH_PROJECT_ID matches the token’s audience claim
- Verify token hasn’t expired (check
exp claim)
Webhook Signature Failures
Symptoms:
- Error: “Invalid signature” (400 status)
- Webhooks not processing
Solutions:
- Verify
STYTCH_WEBHOOK_SECRET is correct
- Check webhook secret format (should start with
whsec_)
- Ensure webhook body is not modified before verification
- Verify headers
svix-id, svix-timestamp, svix-signature are present
User Not Found After Login
Symptoms:
- Login succeeds in Stytch
- Error: “User not found” in Definable
Solutions:
- Check if webhook was processed successfully
- Verify
stytch_id matches between Stytch and UserModel
- Check if user was created with
temp: true flag (skips webhook processing)
- Manually create user if webhook failed
Next Steps