> ## 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.

# Stytch Integration

> How Definable integrates with Stytch for authentication and user management

Definable uses [Stytch](https://stytch.com/) 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

```mermaid theme={null}
flowchart TB
    A[Client] -->|Login Request| B[Stytch API]
    B -->|Session JWT| A
    A -->|JWT in Header| C[Definable API]
    C -->|Verify JWT| D[JWKS Endpoint]
    D -->|Public Keys| C
    C -->|Validated| E[Extract stytch_id]
    E -->|Lookup| F[UserModel]
    F -->|Return User| G[Protected Resources]

    H[Stytch User Events] -->|Webhook| I[Svix Verification]
    I -->|Valid| J[Process Event]
    J -->|CREATE| K[Setup User/Org]
    J -->|INVITATION| L[Link to Org]

    subgraph "Stytch Services"
    B
    D
    H
    end

    subgraph "Definable Backend"
    C
    E
    I
    J
    K
    L
    end
```

## 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`:

```python theme={null}
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:

```python theme={null}
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](https://www.svix.com/) 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`:

```python theme={null}
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:

```python theme={null}
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:**

```python theme={null}
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:**

```python theme={null}
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:

```python theme={null}
# 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

```python theme={null}
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

```python theme={null}
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:

```python theme={null}
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:

```env theme={null}
# 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`:

```python theme={null}
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

```python theme={null}
# 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

* [JWT Authentication](jwt-authentication): General JWT implementation details
* [Invitation Flow](invitation-flow): Complete invitation process documentation
* [Authentication Overview](overview): Complete authentication system overview
* [Troubleshooting](../troubleshooting/authentication-issues): Common authentication issues
