Skip to main content
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

  1. Client receives JWT from Stytch after successful login
  2. Client sends JWT in Authorization: Bearer <token> header
  3. JWTBearer middleware extracts the token
  4. JWKS client fetches public keys from Stytch’s JWKS endpoint (cached)
  5. Token is cryptographically verified using the public key
  6. User lookup using the sub claim (stytch_id) from decoded token
  7. 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

  1. User event occurs in Stytch (e.g., user signs up)
  2. Stytch sends webhook to Definable’s /api/auth endpoint
  3. Svix headers included: svix-id, svix-timestamp, svix-signature
  4. Signature verified using webhook secret
  5. 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:
  1. User signs up via Stytch (password or magic link)
  2. Stytch webhook triggers with action: "CREATE"
  3. UserModel created with stytch_id from webhook
  4. Default organization created and user assigned “owner” role
  5. Default auth token generated (365-day JWT)
  6. 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:
  1. Admin invites user via email
  2. Pre-created UserModel with stytch_id=None, status=“invited”
  3. Invitation email sent via Stytch with trusted_metadata.external_user_id
  4. User clicks invitation link and signs up via Stytch
  5. Stytch webhook triggers with type: "invitation" in untrusted_metadata
  6. UserModel updated with stytch_id from Stytch
  7. OrganizationMember status changed from “invited” to “active”
  8. 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

Metadata Security

  • 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:
  1. Verify token is from correct Stytch environment (test vs live)
  2. Check JWKS endpoint is accessible
  3. Ensure STYTCH_PROJECT_ID matches the token’s audience claim
  4. Verify token hasn’t expired (check exp claim)

Webhook Signature Failures

Symptoms:
  • Error: “Invalid signature” (400 status)
  • Webhooks not processing
Solutions:
  1. Verify STYTCH_WEBHOOK_SECRET is correct
  2. Check webhook secret format (should start with whsec_)
  3. Ensure webhook body is not modified before verification
  4. 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:
  1. Check if webhook was processed successfully
  2. Verify stytch_id matches between Stytch and UserModel
  3. Check if user was created with temp: true flag (skips webhook processing)
  4. Manually create user if webhook failed

Next Steps