How JWT Authentication and Token Refresh Work in PostHog
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 to verification in 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. The system signs payloads using the RS256 algorithm and a private RSA key stored in settings.SANDBOX_JWT_PRIVATE_KEY.
# 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:
# 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 processes the JWT through the respective authentication class (JwtAuthentication, ExportRendererAuthentication, etc.).
The shared decode_jwt helper validates:
- Signature using the RS256 public key
- Expiration (
expclaim) – rejects expired tokens immediately - Audience (
audclaim) – 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:
- Short lifetimes prevent long-term token exposure (24 hours for sandbox tokens, ≤5 minutes for export renders)
- Explicit re-creation occurs when clients detect expiration or receive a 401 response
- 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
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:
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:
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.pyand created viaproducts/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_KEYviaget_sandbox_jwt_public_key. - Refresh mechanism involves generating new tokens through
create_sandbox_connection_tokenwhen 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. 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.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →