# How JWT Authentication and Token Refresh Work in PostHog

> Understand JWT authentication and token refresh in PostHog. Explore stateless RS256 tokens and backend helpers for token regeneration, avoiding traditional refresh endpoints.

- Repository: [PostHog/posthog](https://github.com/PostHog/posthog)
- Tags: deep-dive
- Published: 2026-04-25

---

**PostHog implements stateless JWT authentication using RS256-signed tokens with audience-specific claims, where token refresh occurs by re-generating new tokens via backend helpers rather than using traditional refresh token endpoints.**

PostHog uses JSON Web Tokens (JWT) to authenticate sandbox connections, export renders, and user impersonation workflows. Unlike traditional session-based authentication, the platform implements stateless verification using RSA key pairs and audience-scoped claims. Understanding how JWT authentication and token refresh work in PostHog requires examining the token lifecycle from creation in [`products/tasks/backend/services/connection_token.py`](https://github.com/PostHog/posthog/blob/main/products/tasks/backend/services/connection_token.py) to verification in [`posthog/auth.py`](https://github.com/PostHog/posthog/blob/main/posthog/auth.py).

## JWT Use Cases and Audience Claims

PostHog employs distinct JWT authentication classes for different internal scenarios, each verified against specific audience (`aud`) claims.

### Sandbox Connection Tokens

The **sandbox connection** workflow uses `JwtAuthentication` with the audience claim set to `posthog:sandbox_connection` (defined as `SANDBOX_CONNECTION_AUDIENCE`). These tokens authenticate background jobs and sandbox-to-frontend communication. The payload includes `run_id`, `task_id`, `team_id`, `user_id`, `distinct_id`, and `mode` claims.

### Export Renderer Authentication

The `ExportRendererAuthentication` class validates tokens with the audience `PosthogJwtAudience.EXPORT_RENDERER`. These short-lived tokens (typically ≤5 minutes) authenticate frontend renders of exported data.

### Sharing Links and Impersonation

For password-protected sharing links, `SharingPasswordProtectedAuthentication` validates tokens against `PosthogJwtAudience.SHARING_PASSWORD_PROTECTED`. Meanwhile, `JwtAuthentication` handles **impersonated user** tokens (audience `PosthogJwtAudience.IMPERSONATED_USER`) used by background jobs that require elevated privileges.

## Token Creation and Signing Process

Tokens are created using the `create_sandbox_connection_token` helper function in [`products/tasks/backend/services/connection_token.py`](https://github.com/PostHog/posthog/blob/main/products/tasks/backend/services/connection_token.py). The system signs payloads using the **RS256 algorithm** and a private RSA key stored in `settings.SANDBOX_JWT_PRIVATE_KEY`.

```python

# products/tasks/backend/services/connection_token.py

from datetime import datetime, timedelta, UTC
import jwt

def create_sandbox_connection_token(task_run, user_id, distinct_id):
    private_key = settings.SANDBOX_JWT_PRIVATE_KEY
    
    payload = {
        "run_id": str(task_run.id),
        "task_id": str(task_run.task_id),
        "team_id": task_run.team_id,
        "user_id": user_id,
        "distinct_id": distinct_id,
        "mode": task_run.mode,
        "exp": datetime.now(tz=UTC) + timedelta(hours=24),
        "aud": SANDBOX_CONNECTION_AUDIENCE,  # "posthog:sandbox_connection"

    }
    
    return jwt.encode(payload, private_key, algorithm="RS256")

```

The **24-hour expiry** (`timedelta(hours=24)`) ensures tokens remain short-lived while accommodating long-running background tasks.

## Public Key Derivation and Caching

The sandbox environment verifies tokens using only the public key portion. PostHog derives this via `get_sandbox_jwt_public_key`, which uses Python's `lru_cache` to avoid repeated cryptographic operations:

```python

# products/tasks/backend/services/connection_token.py

from functools import lru_cache
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend

@lru_cache(maxsize=1)
def get_sandbox_jwt_public_key() -> str:
    private_key_pem = _normalize_pem_key(settings.SANDBOX_JWT_PRIVATE_KEY)
    private_key = serialization.load_pem_private_key(
        private_key_pem.encode(), password=None, backend=default_backend()
    )
    public_key = private_key.public_key()
    
    return public_key.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo,
    ).decode()

```

This cached public key enables stateless verification across distributed sandbox instances without exposing the private key.

## Token Verification in posthog/auth.py

When a request arrives with an `Authorization: Bearer <token>` header, the authentication flow in [`posthog/auth.py`](https://github.com/PostHog/posthog/blob/main/posthog/auth.py) processes the JWT through the respective authentication class (`JwtAuthentication`, `ExportRendererAuthentication`, etc.).

The shared `decode_jwt` helper validates:

- **Signature** using the RS256 public key
- **Expiration** (`exp` claim) – rejects expired tokens immediately
- **Audience** (`aud` claim) – ensures the token matches the expected use case

If validation succeeds, the method returns a `User` instance (or anonymous placeholder). Failure raises `AuthenticationFailed`, triggering a 401 response.

## How Token Refresh Works in PostHog

PostHog does not implement traditional OAuth-style refresh tokens. Instead, the platform relies on **stateless re-issuance**:

1. **Short lifetimes** prevent long-term token exposure (24 hours for sandbox tokens, ≤5 minutes for export renders)
2. **Explicit re-creation** occurs when clients detect expiration or receive a 401 response
3. **No server-side revocation** exists due to the stateless nature of JWTs

When a token expires, the client must request a fresh JWT using existing credentials (session cookies, API keys, or OAuth tokens), then retry the request with the new bearer token.

### Handling Token Expiration in Practice

```python
import requests
from products.tasks.backend.services.connection_token import create_sandbox_connection_token

# Initial token request

jwt_token = create_sandbox_connection_token(task_run, user_id=42, distinct_id="user_123")

response = requests.post(
    "https://sandbox.posthog.com/v1/connect",
    headers={"Authorization": f"Bearer {jwt_token}"},
)

if response.status_code == 401:
    # Token expired - generate new JWT and retry immediately

    jwt_token = create_sandbox_connection_token(task_run, user_id=42, distinct_id="user_123")
    response = requests.post(
        "https://sandbox.posthog.com/v1/connect",
        headers={"Authorization": f"Bearer {jwt_token}"},
    )

```

## Implementation Examples

### Authenticating a Sandbox Connection

The following pattern demonstrates authenticating against PostHog sandbox services using the `JwtAuthentication` class:

```python
from rest_framework import viewsets
from posthog.auth import (
    JwtAuthentication,
    SessionAuthentication,
    PersonalAPIKeyAuthentication,
    OAuthAccessTokenAuthentication,
)

class SandboxDataViewSet(viewsets.ViewSet):
    authentication_classes = [
        SessionAuthentication,
        PersonalAPIKeyAuthentication,
        OAuthAccessTokenAuthentication,
        JwtAuthentication,  # Enable JWT-based impersonation

    ]

    def list(self, request):
        # request.user populated from JWT claims if using JwtAuthentication

        return Response({"user_id": request.user.id, "team_id": request.user.team_id})

```

### Generating Export Renderer Tokens

For export renderer workflows, create short-lived tokens with the appropriate audience claim:

```python
from posthog.models.jwt_audience import PosthogJwtAudience

payload = {
    "user_id": user.id,
    "team_id": team.id,
    "exp": datetime.now(tz=UTC) + timedelta(minutes=5),
    "aud": PosthogJwtAudience.EXPORT_RENDERER,
}
token = jwt.encode(payload, private_key, algorithm="RS256")

```

## Summary

- **PostHog JWT authentication** relies on RS256-signed tokens with audience-specific claims defined in [`posthog/auth.py`](https://github.com/PostHog/posthog/blob/main/posthog/auth.py) and created via [`products/tasks/backend/services/connection_token.py`](https://github.com/PostHog/posthog/blob/main/products/tasks/backend/services/connection_token.py).
- **Token lifetimes** are short by design (24 hours for sandbox connections, 5 minutes for exports) to minimize security exposure without maintaining server-side revocation lists.
- **Verification** occurs statelessly using cached public keys derived from `settings.SANDBOX_JWT_PRIVATE_KEY` via `get_sandbox_jwt_public_key`.
- **Refresh mechanism** involves generating new tokens through `create_sandbox_connection_token` when existing tokens expire, rather than using dedicated refresh token endpoints.
- **Audience validation** ensures tokens created for sandbox connections cannot be reused for export renders or sharing links.

## Frequently Asked Questions

### How long do PostHog JWT tokens last?

Sandbox connection tokens expire after **24 hours**, as defined by `timedelta(hours=24)` in `create_sandbox_connection_token`. Export renderer tokens typically expire within **5 minutes**, while sharing link tokens vary based on configuration. PostHog does not support extending token lifetimes; clients must generate new tokens upon expiration.

### What algorithm does PostHog use for JWT signing?

PostHog uses the **RS256** (RSA with SHA-256) algorithm for all JWT operations. The private key resides in `settings.SANDBOX_JWT_PRIVATE_KEY`, while public keys are derived dynamically via `get_sandbox_jwt_public_key` and cached using `lru_cache` for verification.

### Where is the JWT public key stored in PostHog?

The public key is not stored as a static file. Instead, PostHog derives it from the private key at runtime using the `get_sandbox_jwt_public_key` function in [`products/tasks/backend/services/connection_token.py`](https://github.com/PostHog/posthog/blob/main/products/tasks/backend/services/connection_token.py). The result is cached in memory to avoid repeated cryptographic calculations while maintaining security across distributed services.

### How do I handle expired JWT tokens when calling PostHog sandbox services?

When receiving a 401 response, catch the `AuthenticationFailed` exception and call `create_sandbox_connection_token` again with the original task run and user parameters. There is no dedicated refresh endpoint; your application must maintain the credentials necessary to generate fresh tokens (such as session cookies or API keys) and recreate the JWT before retrying the sandbox request.