- Published on
Building GoHighLevel Custom Pages SSO: A Production-Ready Starter with React and FastAPI
- Authors

- Name
- Winston Brown
Building GoHighLevel Custom Pages SSO: A Production-Ready Starter with React and FastAPI
GoHighLevel (GHL) marketplace apps that run inside Custom Pages need a way to identify the logged-in user. GHL solves this with a postMessage-based SSO flow: the parent CRM window sends an AES-encrypted payload containing user context, your app decrypts it server-side using a shared secret, and you get back structured user data (userId, companyId, role, location context).
The official documentation covers the basics, but leaves gaps around the actual decryption implementation in Python, JWT session management, and how to wire the frontend and backend together. This guide fills those gaps with patterns I shipped in a production marketplace app.
The companion starter code is available on GitHub: ghl-custom-pages-sso-starter
How Custom Pages SSO Works
The flow has four steps:
- Your Custom Page loads inside a GHL iframe
- Your frontend sends a
REQUEST_USER_DATApostMessage to the parent window - GHL responds with
REQUEST_USER_DATA_RESPONSEcontaining a base64-encoded, AES-encrypted payload - Your frontend sends this encrypted payload to your backend, which decrypts it and returns a JWT
The encrypted payload uses AES-256-CBC with an OpenSSL-compatible Salted__ header format. The shared secret you generate in your app's Advanced Settings is the decryption key.
Prerequisites
Before starting, you need:
- A GHL marketplace app with a Custom Page configured
- A Shared Secret key generated from your app's Advanced Settings → Auth section
- Node.js 18+ and Python 3.11+
Frontend: Vite + React + TypeScript
The frontend handles two things: requesting the SSO key from GHL via postMessage, and sending it to your backend for decryption.
Project Setup
npm create vite@latest ghl-sso-frontend -- --template react-ts
cd ghl-sso-frontend
npm install react-router-dom
TypeScript Interfaces
Define the contract between frontend and backend:
/** Decrypted GHL user context - Agency level */
export interface GHLUserContext {
userId: string;
companyId: string;
role: string;
type: 'agency' | 'location';
userName: string;
email: string;
isAgencyOwner: boolean;
activeLocation?: string; // Present only in location context
versionId: string;
appStatus: string;
whitelabelDetails: {
domain: string;
logoUrl: string;
};
}
/** Response from POST /sso/decrypt */
export interface SSOLoginResponse {
token_type: 'Bearer';
access_token: string;
expires_in: number;
}
/** Response from GET /sso/session */
export interface SSOSessionResponse {
status: 'success' | 'error';
data: {
userId: string;
companyId: string;
role: string;
type: string;
activeLocation?: string;
};
}
SSO Service
This is the core of the frontend SSO flow. The requestSSOKey function uses postMessage to get the encrypted payload from GHL, then validateSSOKey sends it to your backend:
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
/**
* Request encrypted SSO key from GHL parent window via postMessage.
*
* The GHL CRM parent window responds with REQUEST_USER_DATA_RESPONSE
* containing a base64-encoded AES-encrypted payload.
*/
export async function requestSSOKey(): Promise<string> {
return new Promise<string>((resolve, reject) => {
// Send request to GHL parent window
window.parent.postMessage({ message: 'REQUEST_USER_DATA' }, '*');
const timeout = setTimeout(() => {
window.removeEventListener('message', listener);
reject(new Error('SSO response timed out after 10 seconds'));
}, 10_000);
const listener = (event: MessageEvent) => {
if (event.data.message === 'REQUEST_USER_DATA_RESPONSE') {
clearTimeout(timeout);
window.removeEventListener('message', listener);
resolve(event.data.payload);
}
};
window.addEventListener('message', listener);
});
}
/**
* Send encrypted SSO key to backend for decryption and JWT creation.
* Stores the returned JWT in sessionStorage.
*/
export async function validateSSOKey(ssoKey: string): Promise<void> {
const response = await fetch(`${API_BASE}/sso/decrypt`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key: ssoKey }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'SSO validation failed');
}
const data: SSOLoginResponse = await response.json();
sessionStorage.setItem('access_token', data.access_token);
}
/**
* Fetch current session info using stored JWT.
*/
export async function fetchSession(): Promise<SSOSessionResponse> {
const token = sessionStorage.getItem('access_token');
if (!token) throw new Error('No access token found');
const response = await fetch(`${API_BASE}/sso/session`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.status === 401) {
sessionStorage.removeItem('access_token');
throw new Error('Session expired');
}
if (!response.ok) {
throw new Error(`Session fetch failed: ${response.status}`);
}
return response.json();
}
export function isAuthenticated(): boolean {
return !!sessionStorage.getItem('access_token');
}
export function logout(): void {
sessionStorage.removeItem('access_token');
}
SSO Page Component
The SSO page orchestrates the full flow — request key, validate, fetch session, redirect:
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { requestSSOKey, validateSSOKey, fetchSession } from '../services/ssoService';
export default function SSOPage() {
const navigate = useNavigate();
const [status, setStatus] = useState('Initializing SSO...');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const init = async () => {
try {
setStatus('Requesting SSO key from GHL...');
const ssoKey = await requestSSOKey();
setStatus('Validating credentials...');
await validateSSOKey(ssoKey);
setStatus('Loading session...');
const session = await fetchSession();
// Redirect based on context type
if (session.data.activeLocation) {
navigate(`/location/${session.data.activeLocation}`, { replace: true });
} else {
navigate('/dashboard', { replace: true });
}
} catch (err: any) {
setError(err.message);
}
};
init();
}, [navigate]);
if (error) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>Authentication Failed</h2>
<p>{error}</p>
</div>
);
}
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p>{status}</p>
</div>
);
}
App Router
Wire up the SSO entry point as the default route — GHL will load your Custom Page at the root:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import SSOPage from './pages/SSOPage';
import Dashboard from './pages/Dashboard';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<SSOPage />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/location/:locationId" element={<Dashboard />} />
</Routes>
</BrowserRouter>
);
}
Backend: FastAPI + Python
The backend handles decryption of the GHL payload, JWT creation, and session validation.
Project Setup
mkdir ghl-sso-backend && cd ghl-sso-backend
python -m venv venv && source venv/bin/activate
pip install fastapi uvicorn pycryptodome python-jose[cryptography] pydantic-settings
Configuration
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# GHL Shared Secret from your app's Advanced Settings
ghl_shared_secret: str
# JWT configuration
jwt_secret: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60
# CORS
frontend_url: str = "http://localhost:5173"
class Config:
env_file = ".env"
settings = Settings()
AES-256-CBC Decryption with EVP_BytesToKey
This is the piece the GHL docs don't show in Python. GHL encrypts the SSO payload using CryptoJS, which uses OpenSSL's Salted__ format with MD5-based key derivation. Here's the Python equivalent:
import base64
from hashlib import md5
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
def evp_bytes_to_key(
password: bytes, salt: bytes, key_len: int = 32, iv_len: int = 16
) -> tuple[bytes, bytes]:
"""
OpenSSL-compatible key derivation (EVP_BytesToKey with MD5).
CryptoJS uses this internally when encrypting with a passphrase.
The function derives both the AES key and IV from the password + salt
by iteratively hashing with MD5.
Args:
password: The shared secret as bytes
salt: 8-byte salt extracted from the encrypted payload
key_len: 32 for AES-256
iv_len: 16 for AES block size
"""
d = b""
key_iv = b""
while len(key_iv) < key_len + iv_len:
d = md5(d + password + salt).digest()
key_iv += d
return key_iv[:key_len], key_iv[key_len : key_len + iv_len]
def decrypt_sso_payload(encrypted_data: str, shared_secret: str) -> dict:
"""
Decrypt a GHL SSO payload.
The payload is base64-encoded and uses OpenSSL's Salted__ format:
- Bytes 0-7: 'Salted__' literal
- Bytes 8-15: 8-byte random salt
- Bytes 16+: AES-256-CBC encrypted data with PKCS7 padding
Args:
encrypted_data: Base64-encoded encrypted string from GHL
shared_secret: Your app's Shared Secret key
Returns:
Decrypted user context as a dictionary
"""
import json
raw = base64.b64decode(encrypted_data)
# Validate Salted__ header
if raw[:8] != b"Salted__":
raise ValueError("Invalid payload: missing 'Salted__' header")
salt = raw[8:16]
ciphertext = raw[16:]
# Derive key and IV
key, iv = evp_bytes_to_key(shared_secret.encode("utf-8"), salt)
# Decrypt
cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
return json.loads(plaintext.decode("utf-8"))
Why EVP_BytesToKey? CryptoJS doesn't use standard PBKDF2 when you pass a string passphrase to AES.encrypt(). It uses OpenSSL's legacy key derivation, which prepends Salted__ + an 8-byte salt, then derives the key and IV by iteratively hashing MD5(previous_hash + password + salt). If you try standard AES decryption without this step, you'll get garbage output or padding errors.
JWT Session Management
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from fastapi import HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.config import settings
security = HTTPBearer()
# In-memory session store (use Redis in production)
_sessions: dict[str, dict] = {}
def create_session(user_data: dict) -> str:
"""Create a JWT token from decrypted GHL user data."""
expires = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {
"sub": user_data["userId"],
"company_id": user_data["companyId"],
"role": user_data["role"],
"type": user_data["type"],
"exp": expires,
}
if "activeLocation" in user_data:
payload["active_location"] = user_data["activeLocation"]
token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
# Cache session data for the /session endpoint
_sessions[token] = {
"userId": user_data["userId"],
"companyId": user_data["companyId"],
"role": user_data["role"],
"type": user_data["type"],
"activeLocation": user_data.get("activeLocation"),
}
return token
def get_current_session(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
"""Validate JWT and return session data."""
token = credentials.credentials
try:
payload = jwt.decode(
token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
)
except JWTError:
raise HTTPException(status_code=401, detail="Invalid or expired token")
session = _sessions.get(token)
if not session:
raise HTTPException(status_code=401, detail="Session not found")
return session
API Routes
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from app.config import settings
from app.crypto import decrypt_sso_payload
from app.auth import create_session, get_current_session
app = FastAPI(title="GHL Custom Pages SSO")
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_url],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class SSODecryptRequest(BaseModel):
key: str
@app.post("/sso/decrypt")
async def decrypt_sso(request: SSODecryptRequest):
"""Decrypt GHL SSO payload and return a JWT."""
try:
user_data = decrypt_sso_payload(request.key, settings.ghl_shared_secret)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Decryption failed: {str(e)}")
token = create_session(user_data)
return {
"token_type": "Bearer",
"access_token": token,
"expires_in": settings.jwt_expire_minutes * 60,
}
@app.get("/sso/session")
async def get_session(session: dict = Depends(get_current_session)):
"""Return current session data. Requires valid JWT."""
return {"status": "success", "data": session}
Environment File
GHL_SHARED_SECRET=your-shared-secret-from-ghl-advanced-settings
JWT_SECRET=generate-a-strong-random-string-here
FRONTEND_URL=http://localhost:5173
Running the Backend
uvicorn app.main:app --reload --port 8000
Testing Without GHL
During development, you won't have the GHL parent window available. You can test the backend decryption independently by encrypting a test payload with CryptoJS using your shared secret:
// test-encrypt.js (run with Node.js)
const CryptoJS = require('crypto-js');
const testPayload = {
userId: 'test-user-123',
companyId: 'test-company-456',
role: 'admin',
type: 'agency',
userName: 'Test User',
email: 'test@example.com',
isAgencyOwner: true,
versionId: 'test-version',
appStatus: 'live',
whitelabelDetails: { domain: 'example.com', logoUrl: 'example.com' },
};
const encrypted = CryptoJS.AES.encrypt(
JSON.stringify(testPayload),
'your-shared-secret'
).toString();
console.log('Encrypted payload:', encrypted);
Then POST that encrypted string to your /sso/decrypt endpoint to verify the decryption pipeline works end-to-end.
Security Considerations
A few things I learned shipping this in production:
- Never expose the shared secret client-side. All decryption happens on your backend.
- Validate the postMessage origin in production. The commented-out origin check in the starter should be enabled with your GHL app's origin.
- Use sessionStorage, not localStorage for the JWT. sessionStorage is scoped to the tab and cleared when the tab closes, which matches the iframe lifecycle.
- Set short JWT expiration times. The GHL session context can change (user switches locations, gets role changes). Shorter tokens force re-authentication.
- Use HTTPS everywhere. The encrypted SSO payload travels over postMessage (same-origin policy helps here), but the JWT exchange between your frontend and backend must be over TLS.
Decrypted Payload Structure
After decryption, the payload structure depends on whether the user is in an Agency or Location context:
Agency Context — no activeLocation field:
{
"userId": "MKQJ7wOVVmNOMvrnKKKK",
"companyId": "GNb7aIv4rQFVb9iwNl5K",
"role": "admin",
"type": "agency",
"userName": "John Doe",
"email": "johndoe@gmail.com",
"isAgencyOwner": true,
"versionId": "695505b431a9710730ee67d7",
"appStatus": "live",
"whitelabelDetails": {
"domain": "example.com",
"logoUrl": "example.com"
}
}
Location Context — includes activeLocation:
{
"userId": "MKQJ7wOVVmNOMvrnKKKK",
"companyId": "GNb7aIv4rQFVb9iwNl5K",
"role": "admin",
"type": "agency",
"activeLocation": "yLKVZpNppIdYpah4RjNE",
"userName": "John Doe",
"email": "johndoe@gmail.com",
"isAgencyOwner": false,
"versionId": "695505b431a9710730ee67d7",
"appStatus": "live",
"whitelabelDetails": {
"domain": "example.com",
"logoUrl": "example.com"
}
}
Use the type and activeLocation fields to determine routing and permissions in your app.
From Starter to Production
This starter gives you the core SSO flow. When building a real marketplace app, you'll likely want to extend it with:
- Persistent sessions — Replace the in-memory dict with Redis or a database-backed session store
- Token refresh — Add a refresh endpoint that re-validates the GHL context
- Role-based routing — Use the
role,type, andisAgencyOwnerfields to gate features - Multi-app support — If you have separate agency and location apps, route to different SSO endpoints with different shared secrets
- Protected route wrapper — A React component that checks
isAuthenticated()and redirects to the SSO page if the token is missing
These are patterns I implemented in EasySignupForm, a multi-tenant Medicare enrollment platform where the SSO flow handles agency admins, sub-account users, and spoofed account sessions across different GHL app types.