Published on

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

Authors
  • avatar
    Name
    Winston Brown
    Twitter

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:

  1. Your Custom Page loads inside a GHL iframe
  2. Your frontend sends a REQUEST_USER_DATA postMessage to the parent window
  3. GHL responds with REQUEST_USER_DATA_RESPONSE containing a base64-encoded, AES-encrypted payload
  4. Your frontend sends this encrypted payload to your backend, which decrypts it and returns a JWT
GHL Custom Pages SSO Flow

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:

src/types/sso.ts
/** 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:

src/services/ssoService.ts
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:

src/pages/SSOPage.tsx
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:

src/App.tsx
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

app/config.py
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:

app/crypto.py
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

app/auth.py
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

app/main.py
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

.env
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, and isAgencyOwner fields 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.

Resources