Authenticate your users once and seamlessly pass their identity into every Legion widget — no extra logins required.
Get Credentials
Create an SSO client and receive your Client ID & Secret
Authenticate
Exchange user identity for a short-lived JWT token
Integrate
Pass the token to widgets via React SDK, iframe, or web component
Secure
Server-side secret, origin validation, auto-expiring tokens
Legion SSO lets your existing authentication system issue short-lived JWT tokens that Legion widgets accept automatically. Your users never see a second login screen.
Important: The SSO clientSecret must never be exposed in client-side code. Always call the SSO endpoint from your server.
React SDK Prop
Pass authToken as a prop to any <Legion*> component.
URL Parameter
Append ?token=JWT&origin=… to any widget iframe URL.
Web Component Attribute
Set auth-token on any <legion-*> custom element.
Before you can authenticate users, you need an SSO client with a unique Client ID and Client Secret.
Contact Legion Hand Technologies at [email protected] to request SSO credentials for your organization. Provide your production domain(s) and a contact email.
| Field | Example | Description |
|---|---|---|
clientId | client-a1b2c3d4-… | Public identifier for your SSO integration |
clientSecret | e4f5a6b7-… | Secret key — keep server-side only |
allowedOrigins | ["https://mysite.com"] | Domains authorized to use this client |
tokenExpiryHours | 24 | How long issued JWTs remain valid |
Store your credentials as environment variables — never hard-code them.
1# .env (server-side only — do NOT expose to the browser)
2LEGION_SSO_CLIENT_ID=client-a1b2c3d4-e5f6-7890-abcd-ef1234567890
3LEGION_SSO_CLIENT_SECRET=e4f5a6b7-c8d9-0123-4567-89abcdef0123
4LEGION_BASE_URL=https://your-legion-domain.comVerify everything works with a quick curl call:
1curl -X POST https://your-legion-domain.com/api/auth/sso \
2 -H "Content-Type: application/json" \
3 -d '{
4 "clientId": "YOUR_CLIENT_ID",
5 "clientSecret": "YOUR_CLIENT_SECRET",
6 "userIdentifier": "[email protected]",
7 "userData": {
8 "firstName": "Test",
9 "lastName": "User"
10 }
11 }'
12
13# ✅ Expected response:
14# {
15# "token": "eyJhbGciOiJIUzI1NiIs…",
16# "userId": "uuid-of-user",
17# "expiresIn": "24h"
18# }Use the @legionhandtech/lht-react-widgets NPM package for first-class React support with typed props and SSO built in.
npm install @legionhandtech/lht-react-widgetsThis keeps your clientSecret on the server. Works with both the App Router and Pages Router.
1// app/api/legion-token/route.ts (App Router)
2import { NextResponse } from "next/server";
3
4export async function POST(request: Request) {
5 const { userEmail, firstName, lastName } = await request.json();
6
7 const res = await fetch(
8 `${process.env.LEGION_BASE_URL}/api/auth/sso`,
9 {
10 method: "POST",
11 headers: { "Content-Type": "application/json" },
12 body: JSON.stringify({
13 clientId: process.env.LEGION_SSO_CLIENT_ID,
14 clientSecret: process.env.LEGION_SSO_CLIENT_SECRET,
15 userIdentifier: userEmail,
16 userData: { firstName, lastName, email: userEmail },
17 }),
18 }
19 );
20
21 if (!res.ok) {
22 return NextResponse.json(
23 { error: "SSO authentication failed" },
24 { status: res.status }
25 );
26 }
27
28 const data = await res.json();
29 return NextResponse.json({ token: data.token, expiresIn: data.expiresIn });
30}1// hooks/useLegionToken.ts
2import { useState, useEffect, useCallback } from "react";
3
4export function useLegionToken(user: { email: string; firstName?: string; lastName?: string } | null) {
5 const [token, setToken] = useState<string | null>(null);
6 const [loading, setLoading] = useState(false);
7
8 const fetchToken = useCallback(async () => {
9 if (!user) return;
10 setLoading(true);
11 try {
12 const res = await fetch("/api/legion-token", {
13 method: "POST",
14 headers: { "Content-Type": "application/json" },
15 body: JSON.stringify({
16 userEmail: user.email,
17 firstName: user.firstName,
18 lastName: user.lastName,
19 }),
20 });
21 const data = await res.json();
22 if (data.token) setToken(data.token);
23 } catch (err) {
24 console.error("Failed to get Legion token:", err);
25 } finally {
26 setLoading(false);
27 }
28 }, [user]);
29
30 useEffect(() => { fetchToken(); }, [fetchToken]);
31
32 return { token, loading, refresh: fetchToken };
33}1import { LegionProfile, LegionOpportunities, LegionWallet } from "@legionhandtech/lht-react-widgets";
2import { useLegionToken } from "@/hooks/useLegionToken";
3
4export default function Dashboard() {
5 const user = useCurrentUser(); // your auth hook
6 const { token, loading } = useLegionToken(user);
7
8 if (loading || !token) return <p>Loading widgets…</p>;
9
10 return (
11 <div className="grid grid-cols-2 gap-6">
12 <LegionProfile
13 baseUrl={process.env.NEXT_PUBLIC_LEGION_URL!}
14 authToken={token}
15 theme="light"
16 />
17 <LegionWallet
18 baseUrl={process.env.NEXT_PUBLIC_LEGION_URL!}
19 authToken={token}
20 theme="light"
21 />
22 <LegionOpportunities
23 baseUrl={process.env.NEXT_PUBLIC_LEGION_URL!}
24 authToken={token}
25 count={6}
26 layout="horizontal"
27 theme="light"
28 />
29 </div>
30 );
31}Tip: Store NEXT_PUBLIC_LEGION_URL (the base URL without the secret) in your public env vars so the React components can reference it directly.
A common pattern: WordPress handles content & user accounts via the REST API, while a React (or Next.js) frontend renders the UI and Legion widgets.
Skip the custom Node proxy and install the official Legion SSO WordPress plugin. It runs the SSO exchange entirely inside WP-PHP, keeps the client secret server-side, and auto-injects tokens into <legion-*> components. The recipe below is for headless WP setups where the React frontend lives outside WordPress.
Architecture: WordPress REST API → your Node/Next.js backend (proxies SSO) → React frontend with Legion widgets. The clientSecret lives on your Node server, never in WordPress or the browser.
Use the built-in /wp-json/wp/v2/users/me endpoint (requires authentication via JWT or application passwords) or a custom endpoint.
1// functions.php — optional custom endpoint
2add_action('rest_api_init', function () {
3 register_rest_route('legion/v1', '/me', [
4 'methods' => 'GET',
5 'callback' => function (WP_REST_Request $request) {
6 $user = wp_get_current_user();
7 return [
8 'email' => $user->user_email,
9 'firstName' => $user->first_name,
10 'lastName' => $user->last_name,
11 ];
12 },
13 'permission_callback' => function () {
14 return is_user_logged_in();
15 },
16 ]);
17});1// server/legion-sso.ts (Express or Next.js API route)
2import express from "express";
3const router = express.Router();
4
5router.post("/api/legion-token", async (req, res) => {
6 // 1. Verify the WordPress session (cookie or JWT)
7 const wpUser = await verifyWordPressSession(req);
8 if (!wpUser) return res.status(401).json({ error: "Not authenticated" });
9
10 // 2. Request a Legion token
11 const ssoRes = await fetch(
12 `${process.env.LEGION_BASE_URL}/api/auth/sso`,
13 {
14 method: "POST",
15 headers: { "Content-Type": "application/json" },
16 body: JSON.stringify({
17 clientId: process.env.LEGION_SSO_CLIENT_ID,
18 clientSecret: process.env.LEGION_SSO_CLIENT_SECRET,
19 userIdentifier: wpUser.email,
20 userData: {
21 firstName: wpUser.firstName,
22 lastName: wpUser.lastName,
23 email: wpUser.email,
24 },
25 }),
26 }
27 );
28
29 const data = await ssoRes.json();
30 res.json({ token: data.token, expiresIn: data.expiresIn });
31});1import { LegionProfile, LegionOpportunities } from "@legionhandtech/lht-react-widgets";
2import { useEffect, useState } from "react";
3
4function LegionDashboard() {
5 const [token, setToken] = useState<string | null>(null);
6
7 useEffect(() => {
8 fetch("/api/legion-token", { method: "POST", credentials: "include" })
9 .then(r => r.json())
10 .then(d => setToken(d.token))
11 .catch(console.error);
12 }, []);
13
14 if (!token) return <p>Authenticating…</p>;
15
16 return (
17 <>
18 <LegionProfile
19 baseUrl="https://your-legion-domain.com"
20 authToken={token}
21 theme="light"
22 />
23 <LegionOpportunities
24 baseUrl="https://your-legion-domain.com"
25 authToken={token}
26 count={4}
27 layout="vertical"
28 theme="light"
29 />
30 </>
31 );
32}No framework required. Use web components or iframes with a simple fetch call to your backend for the token.
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <title>My Site</title>
5 <script src="https://your-legion-domain.com/js/profile-widget.js"></script>
6 <script src="https://your-legion-domain.com/js/opportunities-widget.js"></script>
7</head>
8<body>
9 <legion-profile id="profile" base-url="https://your-legion-domain.com" theme="light"></legion-profile>
10 <legion-opportunities id="opps" base-url="https://your-legion-domain.com" count="4" theme="light"></legion-opportunities>
11
12 <script>
13 // Fetch token from YOUR server (keeps clientSecret safe)
14 fetch("/api/legion-token", { method: "POST", credentials: "include" })
15 .then(r => r.json())
16 .then(({ token }) => {
17 document.getElementById("profile").setAttribute("auth-token", token);
18 document.getElementById("opps").setAttribute("auth-token", token);
19 });
20 </script>
21</body>
22</html>1<div id="widget-container"></div>
2
3<script>
4 async function loadWidget() {
5 const res = await fetch("/api/legion-token", {
6 method: "POST",
7 credentials: "include",
8 });
9 const { token } = await res.json();
10
11 const origin = encodeURIComponent(window.location.origin);
12 const iframe = document.createElement("iframe");
13 iframe.src = `https://your-legion-domain.com/widget/profile?token=${token}&origin=${origin}`;
14 iframe.width = "100%";
15 iframe.height = "400";
16 iframe.style.border = "none";
17 iframe.style.borderRadius = "12px";
18 document.getElementById("widget-container").appendChild(iframe);
19 }
20
21 loadWidget();
22</script>Load the iframe first, then send the token after it's ready. Useful when the token arrives asynchronously.
1const iframe = document.querySelector("#legion-iframe");
2
3iframe.addEventListener("load", async () => {
4 const res = await fetch("/api/legion-token", {
5 method: "POST",
6 credentials: "include",
7 });
8 const { token } = await res.json();
9
10 iframe.contentWindow.postMessage(
11 { type: "AUTH_TOKEN", token },
12 "https://your-legion-domain.com"
13 );
14});Your backend is the only place that should know the clientSecret. Here are examples for popular server frameworks.
1import express from "express";
2
3const app = express();
4app.use(express.json());
5
6app.post("/api/legion-token", async (req, res) => {
7 // 1. Verify the user is authenticated in YOUR system
8 const user = await getAuthenticatedUser(req); // your auth middleware
9 if (!user) return res.status(401).json({ error: "Unauthorized" });
10
11 // 2. Exchange for a Legion token
12 const response = await fetch(
13 `${process.env.LEGION_BASE_URL}/api/auth/sso`,
14 {
15 method: "POST",
16 headers: { "Content-Type": "application/json" },
17 body: JSON.stringify({
18 clientId: process.env.LEGION_SSO_CLIENT_ID,
19 clientSecret: process.env.LEGION_SSO_CLIENT_SECRET,
20 userIdentifier: user.email,
21 userData: {
22 firstName: user.firstName,
23 lastName: user.lastName,
24 email: user.email,
25 phone: user.phone,
26 },
27 }),
28 }
29 );
30
31 const data = await response.json();
32 if (!response.ok) return res.status(response.status).json(data);
33
34 res.json({ token: data.token, expiresIn: data.expiresIn });
35});1import os, requests
2from flask import Flask, request, jsonify
3
4app = Flask(__name__)
5
6@app.route("/api/legion-token", methods=["POST"])
7def get_legion_token():
8 user = get_authenticated_user(request) # your auth check
9 if not user:
10 return jsonify(error="Unauthorized"), 401
11
12 resp = requests.post(
13 f"{os.environ['LEGION_BASE_URL']}/api/auth/sso",
14 json={
15 "clientId": os.environ["LEGION_SSO_CLIENT_ID"],
16 "clientSecret": os.environ["LEGION_SSO_CLIENT_SECRET"],
17 "userIdentifier": user["email"],
18 "userData": {
19 "firstName": user.get("first_name"),
20 "lastName": user.get("last_name"),
21 "email": user["email"],
22 },
23 },
24 )
25
26 data = resp.json()
27 if not resp.ok:
28 return jsonify(data), resp.status_code
29
30 return jsonify(token=data["token"], expiresIn=data["expiresIn"])The single endpoint that exchanges your SSO credentials + user identity for a JWT token.
| Field | Type | Required | Description |
|---|---|---|---|
clientId | string | Required | Your SSO client ID |
clientSecret | string | Required | Your SSO client secret |
userIdentifier | string | Required | User's email address or phone number |
userData | object | Optional | Additional user profile data (see below) |
userData.firstName | string | Optional | User's first name |
userData.lastName | string | Optional | User's last name |
userData.email | string | Optional | Secondary email (if identifier is phone) |
userData.phone | string | Optional | Secondary phone (if identifier is email) |
1{
2 "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0…",
3 "userId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
4 "expiresIn": "24h"
5}The decoded JWT contains:
1{
2 "userId": "a1b2c3d4-…",
3 "sub": "[email protected]",
4 "iss": "client-a1b2c3d4-…",
5 "aud": "legion-widgets",
6 "iat": 1708900000,
7 "exp": 1708986400
8}| Status | Body | Cause |
|---|---|---|
400 | {"error":"clientId, clientSecret, and userIdentifier are required"} | Missing required fields |
401 | {"error":"Invalid client credentials"} | Wrong clientId or clientSecret |
401 | {"error":"SSO client not found or inactive"} | Client deactivated or deleted |
500 | {"error":"Internal server error"} | Server-side issue — contact support |
If the userIdentifier doesn't match an existing user, Legion automatically creates a new account using the provided userData. On subsequent calls, ifuserData is provided and the user already exists, missing fields (like a phone number) will be backfilled — existing data is never overwritten.
If widgets show "Authentication Required" or API calls return 401, work through these checks in order.
Symptoms: 401 Invalid client credentials from/api/auth/sso.
Fix: Double-check clientId and clientSecret in your environment variables. Ensure the SSO client is marked active. Contact support if you need new credentials.
Symptoms: Token generation works, but widgets still show "Authentication Required". Browser console shows CORS errors.
Fix: Your page's origin (window.location.origin) must exactly match one of the allowedOrigins in your SSO client config. Common mismatches: http vs https, trailing slashes,www vs non-www, different ports.
// Debug: check your actual origin
console.log("My origin:", window.location.origin);
// Must match exactly: "https://mysite.com" (no trailing slash)Symptoms: Token is generated successfully but the widget doesn't receive it.
Checklist:
authToken prop is not null or undefinedtoken and origin URL params are present and URL-encodedauth-token attribute is set after the element is in the DOMSymptoms: Widget works initially but fails after some time. API calls return 401.
Fix: Tokens expire based on your SSO client's tokenExpiryHours setting. Implement token refresh logic:
1// Decode and check expiry
2const payload = JSON.parse(atob(token.split(".")[1]));
3const expiresAt = new Date(payload.exp * 1000);
4const isExpired = expiresAt < new Date();
5
6if (isExpired) {
7 // Re-fetch from your /api/legion-token endpoint
8 const { token: newToken } = await refreshLegionToken();
9 // Update widgets with new token
10}Symptoms: You see your clientSecret in browser dev tools (Network tab or source code).
Fix: Never call /api/auth/sso directly from client-side code. Always proxy through your own backend. If your secret has been exposed, contact support immediately to rotate it.
Cause: Your SSO client's allowedOrigins only includes localhost URLs.
Fix: Contact Legion Hand Technologies to add your production domain(s) to the allowed origins list:
1{
2 "allowedOrigins": [
3 "https://mysite.com",
4 "https://www.mysite.com",
5 "http://localhost:3000"
6 ]
7}allowedOriginscurl test returns a valid token (3-part JWT)aud: "legion-widgets" and a valid userIdexp claim is in the future)/api/user/profile returns 200 (not 401) in the Network tabContact Legion Hand Technologies for SSO client setup, origin configuration, or integration support.