docs/published/handbook/engineering/oauth-development-guide.md
This guide helps developers set up and test PostHog's OAuth apps locally.
OAuth uses RS256 for signing JWT tokens. Copy the RSA private key from the example file:
# Copy the OIDC_RSA_PRIVATE_KEY from .env.example to your .env file
grep OIDC_RSA_PRIVATE_KEY .env.example >> .env
Or generate a new key pair:
# Generate a new RSA private key
openssl genrsa 2048 | openssl pkcs8 -topk8 -nocrypt -outform PEM | \
awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}'
# Add to .env as OIDC_RSA_PRIVATE_KEY="<generated_key>"
First, generate demo data which includes a test OAuth application:
python manage.py generate_demo_data
After running generate_demo_data, a test OAuth application is created with these credentials:
DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZGQItUP4GqE6t5kjcWIRfWO9c0GXPCY8QDV4eszH4PnxXwCVxIMVSil4Agit7yay249jasnzHEkkVqHnFMxI1YTXSrh8Bj1sl1IDfNi1S95sv208NOc0eoUBP3TdA7vf0http://localhost:3000/callback, https://example.com/callback, http://localhost:8237/callback, http://localhost:8239/callbackYou can view and test the OAuth flow from Django admin:
http://localhost:8010/admin/posthog/oauthapplication/http://localhost:8010/admin/posthog/oauthapplication/Name (required)
Client ID (auto-generated)
DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZClient Secret (auto-generated)
Client Type (required)
Confidential: For server-side applications that can securely store secretsPublic: For client-side apps (mobile, SPA) that cannot securely store secretsSee Client Types section for detailed explanation.
Authorization Grant Type (required, fixed)
Authorization code is supportedRedirect URIs (required)
Whitespace-separated list of valid redirect URIs
PostHog will only redirect to these URIs after authorization
HTTPS required for non-localhost URIs
HTTP allowed only for localhost/loopback addresses (127.0.0.1)
No fragments (#) allowed
Examples:
https://app.example.com/oauth/callback
http://localhost:3000/callback
http://127.0.0.1:8080/auth
Algorithm (required, fixed)
RS256 (RSA with SHA-256) is supportedUser
Organization
Use for: Server-side applications, backend services, traditional web apps
Characteristics:
Examples:
Security: Higher - the secret never leaves your secure server environment
Use for: Single-page apps (SPAs), mobile apps, desktop apps
Characteristics:
Examples:
Generate PKCE parameters (client-side):
import secrets
import hashlib
import base64
# Generate random code_verifier
code_verifier = secrets.token_urlsafe(32)
# Create code_challenge
digest = hashlib.sha256(code_verifier.encode('utf-8')).digest()
code_challenge = base64.urlsafe_b64encode(digest).decode('utf-8').replace('=', '')
Redirect user to authorization URL:
GET /oauth/authorize/
?response_type=code
&client_id=DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ
&redirect_uri=http://localhost:3000/callback
&scope=openid experiment:read query:read
&code_challenge=<generated_code_challenge>
&code_challenge_method=S256
&state=<random_state_value>
User authorizes the application and selects access level
Receive authorization code at redirect_uri:
http://localhost:3000/callback?code=<authorization_code>&state=<state_value>
Exchange code for tokens:
POST /oauth/token/
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<authorization_code>
&redirect_uri=http://localhost:3000/callback
&client_id=DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ
&client_secret=<client_secret> # Only for confidential clients
&code_verifier=<original_code_verifier>
Response includes:
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 36000,
"refresh_token": "...",
"id_token": "...",
"scope": "openid experiment:read query:read",
"scoped_teams": [1, 2],
"scoped_organizations": ["org-uuid"]
}
OAuth supports all the same scopes as Personal API Keys. Each scope has a read and/or write action (e.g., experiment:read, experiment:write).
For a complete list of available scopes, see frontend/src/lib/scopes.tsx.
Standard OpenID Connect scopes are also supported:
openid - Required for OpenID Connect (provides ID token with user identity claims)profile - Access to user profile information (name, username, etc.)email - Access to user email addressWhen authorizing an application, users can scope access to:
This is configured during the authorization step, not in the application settings.
If you would like to force the user to pick a single team or an organization you can use the required_access_level=project or required_access_level=organization query parameter in the authorization url.
http://localhost:8010/admin/posthog/oauthapplication/Use the demo application credentials to test the full flow:
# Example using requests library
import requests
import secrets
import hashlib
import base64
# Step 1: Generate PKCE
code_verifier = "test" # Use something random in production
digest = hashlib.sha256(code_verifier.encode('utf-8')).digest()
code_challenge = base64.urlsafe_b64encode(digest).decode('utf-8').replace('=', '')
# Step 2: Build authorization URL
auth_url = (
"http://localhost:8010/oauth/authorize/"
"?response_type=code"
"&client_id=DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ"
"&redirect_uri=http://localhost:3000/callback"
"&scope=openid+experiment:read"
f"&code_challenge={code_challenge}"
"&code_challenge_method=S256"
"&state=random_state_123"
)
print(f"Visit: {auth_url}")
# Step 3: After authorization, extract code from redirect
# Step 4: Exchange for tokens
token_response = requests.post(
"http://localhost:8010/oauth/token/",
data={
"grant_type": "authorization_code",
"code": "<code_from_redirect>",
"redirect_uri": "http://localhost:3000/callback",
"client_id": "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ",
"client_secret": "GQItUP4GqE6t5kjcWIRfWO9c0GXPCY8QDV4eszH4PnxXwCVxIMVSil4Agit7yay249jasnzHEkkVqHnFMxI1YTXSrh8Bj1sl1IDfNi1S95sv208NOc0eoUBP3TdA7vf0",
"code_verifier": code_verifier,
}
)
tokens = token_response.json()
print(tokens)
/oauth/authorize//oauth/token//oauth/introspect//oauth/userinfo//oauth/.well-known/jwks.json/oauth/.well-known/openid-configuration/The introspection endpoint (/oauth/introspect/) allows you to check if a token is active and retrieve metadata about it. This is useful for validating tokens, checking their scopes or their scoped_teams and scoped_organizations.
The introspection endpoint supports three authentication methods:
curl -X POST http://localhost:8010/oauth/introspect/ \
-u "CLIENT_ID:CLIENT_SECRET" \
-d "token=ACCESS_TOKEN_TO_INTROSPECT"
curl -X POST http://localhost:8010/oauth/introspect/ \
-d "client_id=CLIENT_ID" \
-d "client_secret=CLIENT_SECRET" \
-d "token=ACCESS_TOKEN_TO_INTROSPECT"
curl -X POST http://localhost:8010/oauth/introspect/ \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-d "token=TOKEN_TO_INTROSPECT"
Important: When using Bearer token authentication (method 3), the bearer token must have the introspection scope. Client authentication methods (1 and 2) do not require any scopes.
introspection scopeThis means you can introspect any token using your application's client credentials, regardless of what scopes the token being introspected has. However, if you want to use an access token to introspect other tokens, that access token must have been granted the introspection scope.
Active Token Response:
{
"active": true,
"scope": "openid experiment:read query:read",
"client_id": "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ",
"scoped_teams": [1, 2],
"scoped_organizations": ["org-uuid-1", "org-uuid-2"],
"exp": 1704067200
}
Inactive/Invalid Token Response:
{
"active": false
}
"active": true if valid and not expired"active": false (refresh tokens cannot be introspected)import requests
response = requests.post(
"http://localhost:8010/oauth/introspect/",
auth=("DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ", "CLIENT_SECRET"),
data={"token": "access_token_to_check"}
)
data = response.json()
if data.get("active"):
print(f"Token is active with scopes: {data['scope']}")
print(f"Token has access to teams: {data['scoped_teams']}")
else:
print("Token is inactive or invalid")
To introspect tokens using another access token, ensure the bearer token has the introspection scope:
# First, get an access token WITH introspection scope
# scope=openid+introspection
import requests
response = requests.post(
"http://localhost:8010/oauth/introspect/",
headers={"Authorization": f"Bearer {access_token_with_introspection_scope}"},
data={"token": "token_to_check"}
)
data = response.json()
print(f"Token active: {data.get('active')}")
https://localhost:8010/admin/posthog/oauthapplication/