Integration Guide
Learn how to integrate 3PM Auth SSO into your MERN Stack, Next.js, and Flutter applications with our comprehensive guide.
Overview
3PM Auth is a redirect-based Single Sign-On (SSO) Identity Provider that handles authentication across your applications. It supports multiple authentication methods, multi-tenancy for B2B applications, and provides a seamless experience for your users.
Using AI Coding Assistants?
Download our LLM-optimized integration guide and provide it to Cursor, Claude Code, GitHub Copilot, or any AI assistant for seamless SSO integration.
Email OTP
One-time password via email for secure passwordless login.
Mobile OTP
SMS-based verification for mobile-first authentication.
Google OAuth
Sign in with Google for quick and trusted authentication.
Apple OAuth
Sign in with Apple for privacy-focused authentication.
Key Concepts
Quick Start
Save Your Credentials
Copy your Client ID and Client Secret. Store them securely in environment variables.
# 3PM Auth ConfigurationIDP_URL=https://idp.3pm.appCLIENT_ID=3pm_xxxxxxxxxxxxxxxxCLIENT_SECRET=3pm_secret_xxxxxxxxxxxxxxxxxxxxxxxx
Implement the Auth Flow
Follow the integration guide below for your specific framework (MERN, Next.js, or Flutter).
Environment Setup
Configure your environment variables for development, staging, and production deployments.
Client Application Variables
These are the minimum required variables for integrating 3PM Auth into your application:
# 3PM Auth Configuration (Client App)IDP_URL=https://idp.3pm.appCLIENT_ID=3pm_xxxxxxxxxxxxxxxxCLIENT_SECRET=3pm_secret_xxxxxxxxxxxxxxxxxxxxxxxx# Your app's public URLNEXT_PUBLIC_APP_URL=http://localhost:3000
CLIENT_SECRET on the client-side. It must only be used in server-side code.IdP Server Variables
If you're deploying the 3PM Auth IdP server, configure these environment variables:
| Variable | Required | Description |
|---|---|---|
| MONGODB_URI | Yes | MongoDB connection string |
| MONGODB_DB_NAME | Yes | Database name (e.g., 3pm-auth) |
| JWT_SECRET | Yes | Secret for signing JWTs (64+ chars) |
| ADMIN_SETUP_SECRET | Yes | Secret for initial admin setup |
| NEXT_PUBLIC_APP_URL | Yes | Public URL of the IdP (with https://) |
| SENDGRID_API_KEY | For Email OTP | SendGrid API key for email delivery |
| TWILIO_ACCOUNT_SID | For SMS OTP | Twilio account SID |
| GOOGLE_CLIENT_ID | For Google OAuth | Google OAuth client ID |
| APPLE_CLIENT_ID | For Apple OAuth | Apple Sign In service ID |
| SESSION_TIMEOUT_MINUTES | No | Global session timeout (default: 1440 = 24h) |
| CORS_ALLOWED_ORIGINS | No | Comma-separated allowed origins |
Generating Secure Secrets
Use these commands to generate cryptographically secure secrets:
# Generate JWT secret (64+ characters)openssl rand -base64 64# Generate admin setup secretopenssl rand -hex 32
Production Deployment Checklist
Authentication Flow
3PM Auth uses a redirect-based authentication flow. Here's how it works:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client App │ │ 3PM Auth │ │ Your API │
│ (Browser) │ │ (IdP) │ │ (Backend) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ 1. User visits │ │
│ protected page │ │
│───────────────────> │
│ │ │
│ 2. No session, │ │
│ redirect to IdP│ │
│<──────────────────│ │
│ │ │
│ 3. User logs in │ │
│ on IdP │ │
│ │ │
│ 4. Redirect back │ │
│ with ?guid=xxx │ │
│<──────────────────│ │
│ │ │
│ 5. Send GUID to │ │
│ your backend ────────────────────>
│ │ │
│ │ 6. Exchange GUID │
│ │ for JWT │
│ │<──────────────────│
│ │ │
│ │ 7. Return JWT + │
│ │ user data │
│ │──────────────────>│
│ │ │
│ 8. Set session │ │
│ cookie │ │
│<───────────────────────────────────────
│ │ │
└───────────────────┴───────────────────┘Step-by-Step
- 1.User accesses a protected page on your app → Your app detects no session
- 2.Redirect to IdP:
/authorize?clientId=xxx&next=callback-url - 3.User authenticates using email, mobile, Google, or Apple
- 4.IdP redirects back:
callback-url?guid=xxxxxxxx - 5.Your server exchanges GUID for JWT via
POST /api/exchange-token - 6.Set JWT in secure HTTP-only cookie, redirect user
New User Registration
3PM Auth handles new user registration differently based on the authentication method used.
OAuth Providers
Users signing in via Google or Apple are automatically registered with their profile information (name, email, picture).
OTP Authentication
New users via email/mobile OTP go through a two-step registration flow with profile completion.
OTP Registration Flow
┌────────────────────────────────────────────────────────────────┐ │ New User OTP Registration Flow │ ├────────────────────────────────────────────────────────────────┤ │ │ │ 1. User enters email/mobile │ │ ↓ │ │ 2. OTP sent & verified ✓ │ │ ↓ │ │ 3. System checks: User exists? │ │ ├─── YES → Login successful → Redirect with GUID │ │ └─── NO → Return registration token │ │ ↓ │ │ 4. Show profile form (firstName, lastName) │ │ ↓ │ │ 5. Submit profile → Complete registration │ │ ↓ │ │ 6. Login successful → Redirect with GUID │ │ │ └────────────────────────────────────────────────────────────────┘
OTP Verification Responses
Existing User (Login Success)
{"data": {"session": {"userId": "507f1f77bcf86cd799439011","email": "user@example.com","firstName": "John","lastName": "Doe","profilePicUrl": "https://example.com/avatar.jpg"}},"error": null}
New User (Requires Profile Completion)
{"data": {"requiresProfile": true,"registrationToken": "eyJhbGciOiJIUzI1NiIs...","identifier": "newuser@example.com","type": "email"},"error": null}
Handling New Users in Your App
When your app receives requiresProfile: true, you should temporarily store the registration token and display a profile completion form. After collecting the user's name, submit it to complete registration.
// After successful token exchangeconst { jwt, user } = await auth.exchangeToken(guid);// Check if profile is incomplete (new OTP user)if (!user.firstName || user.firstName.trim() === "") {// New user - redirect to onboardingnavigate("/onboarding", {state: { user, originalDestination: returnUrl }});} else {// Existing user - normal flownavigate(returnUrl);}
Multi-Tenancy
3PM Auth supports multi-tenancy for B2B SaaS applications, allowing organizations to manage their own members and control access to applications.
Organizations
Create tenants (workspaces) to group users and manage access.
Role-Based Access
Owner, Admin, and Member roles with hierarchical permissions.
App Subscriptions
Tenants subscribe to apps, admins assign access to members.
Member Invitations
Invite members via email with secure links (7-day expiry).
Role Hierarchy
Tenant-Based Applications
When creating an app in the dashboard, enable "Tenant Based" to require tenant membership for authentication:
- 1.User must belong to a tenant
- 2.Tenant must subscribe to the application
- 3.Owners/admins have implicit access; members need explicit assignment
- 4.JWT includes
tenantId,tenantName,tenantSlug, andtenantRole
Tenant Admin Dashboard
Access the tenant admin portal at /tenant-admin to manage your organization:
| Feature | Owner | Admin | Member |
|---|---|---|---|
| View dashboard stats | ✓ | ✓ | ✓ |
| Invite members | ✓ | ✓ | ✗ |
| Manage member roles | ✓ | ✓ | ✗ |
| Assign apps to members | ✓ | ✓ | ✗ |
| Subscribe to apps | ✓ | ✗ | ✗ |
| Tenant settings | ✓ | ✗ | ✗ |
Tenant Lifecycle
Understanding the complete tenant lifecycle helps you integrate multi-tenancy features into your application.
1Tenant Creation
When a user first accesses a tenant-based application, they need to create or join a tenant:
- • User authenticates via 3PM Auth
- • If no tenant membership exists, user is prompted to create a new organization
- • Creator automatically becomes the Owner
- • Tenant is assigned a unique ID and name
2App Subscription
The Owner subscribes the tenant to applications:
- • Navigate to Tenant Admin Dashboard → Apps
- • Browse available tenant-based applications
- • Click "Subscribe" to add the app to the tenant
- • Subscription can be suspended or cancelled by the owner
3Member Invitation
┌─────────────────────────────────────────────────────────────────┐ │ Member Invitation Flow │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. Admin/Owner clicks "Invite Member" │ │ ↓ │ │ 2. Enters email and selects role (admin/member) │ │ ↓ │ │ 3. System sends invitation email with secure link │ │ ↓ │ │ 4. Invitee clicks link in email │ │ ├─── Not logged in → Redirect to login first │ │ └─── Logged in → Accept invitation │ │ ↓ │ │ 5. User added to tenant with specified role │ │ ↓ │ │ 6. Redirect to tenant admin dashboard │ │ │ │ Note: Invitations expire after 7 days │ │ │ └─────────────────────────────────────────────────────────────────┘
4App Assignment
Admins and Owners have implicit access to all subscribed apps and can assign apps to members:
- • Navigate to Tenant Admin → Apps → [App Name]
- • View list of members and their current access status
- • Toggle access for each member
- • Members can only access apps they've been explicitly assigned
- • Owners and admins have automatic access to all subscribed apps
Tenant Context in JWT
When a user authenticates to a tenant-based app, the JWT includes tenant information:
{"userId": "507f1f77bcf86cd799439011","email": "user@example.com","firstName": "John","lastName": "Doe","tenantId": "60d5ecf4f1b5a23456789012","tenantRole": "admin","iat": 1699999999,"exp": 1700086399,"iss": "3pm-auth-idp"}
tenantId and tenantRole on your server for tenant-scoped operations.Tenant Switching
Users can belong to multiple tenants. Use these APIs to switch between tenants:
GET/api/tenant/list
Fetch all tenants the authenticated user belongs to.
// Response{"data": {"tenants": [{ "id": "...", "name": "Acme Corp", "slug": "acme", "role": "owner", "isActive": true },{ "id": "...", "name": "Beta Inc", "slug": "beta", "role": "member", "isActive": false }],"activeTenantId": "60d5ecf4f1b5a23456789012"},"error": null}
POST/api/tenant/switch
Switch to a different tenant. Sets the active_tenant cookie.
// Request{ "tenantId": "60d5ecf4f1b5a23456789012" }// Response{"data": {"tenant": { "id": "...", "name": "Acme Corp", "slug": "acme", "role": "owner" }},"error": null}
Example: Tenant Switcher Component
function TenantSwitcher() {const [tenants, setTenants] = useState([]);const [activeTenantId, setActiveTenantId] = useState("");useEffect(() => {fetch("/api/tenant/list", { credentials: "include" }).then(res => res.json()).then(data => {setTenants(data.data?.tenants || []);setActiveTenantId(data.data?.activeTenantId || "");});}, []);const switchTenant = async (tenantId) => {await fetch("/api/tenant/switch", {method: "POST",headers: { "Content-Type": "application/json" },credentials: "include",body: JSON.stringify({ tenantId }),});window.location.reload();};return (<select value={activeTenantId} onChange={(e) => switchTenant(e.target.value)}>{tenants.map((t) => (<option key={t.id} value={t.id}>{t.name} ({t.role})</option>))}</select>);}
API Reference
/api/exchange-tokenExchange a GUID for a JWT token and user data.
Request Body
{"guid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","clientId": "3pm_xxxxxxxxxxxxxxxx","clientSecret": "3pm_secret_xxxxxxxxxxxxxxxxxxxxxxxx"}
Success Response (200)
{"data": {"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","user": {"id": "507f1f77bcf86cd799439011","email": "user@example.com","firstName": "John","lastName": "Doe","profilePicUrl": "https://example.com/avatar.jpg"},"tenant": { // Only for tenant-based apps"id": "60d5ecb8b4e4a12345678901","name": "Acme Corporation","slug": "acme-corp","role": "admin"}},"error": null}
Error Response (401)
{"data": null,"error": "Invalid or expired token"}
/authorizeInitiates the SSO handshake. Redirect users here to start authentication.
Query Parameters
| Parameter | Required | Description |
|---|---|---|
| clientId | Yes | Your application's Client ID |
| next | Yes | The URL to redirect back to after authentication |
Example
https://your-idp.com/authorize?clientId=3pm_xxx&next=https://yourapp.com/auth/callback
/api/verify-tokenVerify a JWT token and get user data. Use this endpoint instead of verifying tokens locally—the JWT secret is never shared with client applications.
Request Body
{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","clientId": "3pm_xxxxxxxxxxxxxxxx","clientSecret": "3pm_secret_xxxxxxxxxxxxxxxxxxxxxxxx"}
Success Response (200)
{"data": {"valid": true,"user": {"id": "507f1f77bcf86cd799439011","email": "user@example.com","firstName": "John","lastName": "Doe","profilePicUrl": "https://example.com/avatar.jpg"},"tenant": { // Only for tenant-based apps"id": "60d5ecb8b4e4a12345678901","name": "Acme Corporation","slug": "acme-corp","role": "admin"},"issuedAt": 1699999999,"expiresAt": 1700086399},"error": null}
Error Response (401)
{"data": null,"error": "Invalid or expired token"}
/api/signoutSign out the user by clearing the IdP session cookie. Supports both GET (for redirect-based logout) and POST methods.
Query Parameters (GET)
| Parameter | Required | Description |
|---|---|---|
| redirect | No | URL to redirect after sign out |
Example
GET /api/signout?redirect=https://yourapp.com/logged-out
/api/healthHealth check endpoint for monitoring and load balancers. Returns the status of the service and its dependencies.
Success Response (200)
{"status": "healthy","timestamp": "2026-01-25T12:00:00.000Z","version": "1.0.0","environment": "production","checks": {"database": { "status": "healthy", "latencyMs": 5 },"jwt_config": { "status": "healthy" }}}
Error Response (503)
{"status": "unhealthy","timestamp": "2026-01-25T12:00:00.000Z","checks": {"database": { "status": "unhealthy", "error": "Connection timeout" }}}
/api/setupInitial system setup to create the first admin user. This endpoint can only be called once and requires the ADMIN_SETUP_SECRET in the Authorization header.
Headers
Authorization: Bearer YOUR_ADMIN_SETUP_SECRET
Request Body
{"email": "admin@yourcompany.com","password": "YourSecureP@ssword123!","name": "Admin User"}
Success Response (200)
{"data": {"message": "Admin user created successfully","adminId": "507f1f77bcf86cd799439011"},"error": null}
/api/init-dbInitialize database indexes for optimal performance. Call this after deploying to a new environment. Requires the ADMIN_SETUP_SECRET in the Authorization header.
Headers
Authorization: Bearer YOUR_ADMIN_SETUP_SECRET
Success Response (200)
{"data": {"message": "Database indexes created successfully","indexes": ["users", "applications", "auth_tokens", "otps", "tenants", "tenant_members"]},"error": null}
/api/invite/acceptAccept a tenant invitation. Called when a user clicks the invitation link in their email.
Query Parameters
| Parameter | Required | Description |
|---|---|---|
| token | Yes | Invitation token from the email |
Behavior
- If user is not logged in: Redirects to login with invitation context
- If user is logged in: Accepts invitation and redirects to tenant admin dashboard
- If token is expired (7 days): Redirects to login with error message
/api/tenant/listFetch all tenants the authenticated user belongs to. Requires a valid session cookie.
Response (200)
{"data": {"tenants": [{ "id": "...", "name": "Acme Corp", "slug": "acme", "role": "owner", "isActive": true },{ "id": "...", "name": "Beta Inc", "slug": "beta", "role": "member", "isActive": false }],"activeTenantId": "60d5ecf4f1b5a23456789012"},"error": null}
/api/tenant/switchSwitch to a different tenant. Sets the active_tenant cookie after verifying user membership.
Request Body
{"tenantId": "60d5ecf4f1b5a23456789012"}
Response (200)
{"data": {"tenant": {"id": "60d5ecf4f1b5a23456789012","name": "Acme Corporation","slug": "acme-corp","role": "admin"}},"error": null}
Error Responses
| Status | Error |
|---|---|
| 400 | Missing tenantId / Invalid tenant ID |
| 401 | Not authenticated |
| 403 | You are not a member of this tenant |
| 404 | Tenant not found |
Integration Guide
Next.js App Router (Recommended)
This guide uses the App Router with Server Components and Route Handlers.
2. Create Auth SDK (lib/3pm-auth.ts)
export interface ThreePMAuthConfig {idpUrl: string;clientId: string;clientSecret?: string;}export interface ThreePMUser {id: string;email: string;firstName: string;lastName: string;profilePicUrl?: string;}export class ThreePMAuth {private config: ThreePMAuthConfig;constructor(config: ThreePMAuthConfig) {this.config = config;}getLoginUrl(returnUrl: string): string {const loginUrl = new URL("/authorize", this.config.idpUrl);loginUrl.searchParams.set("clientId", this.config.clientId);loginUrl.searchParams.set("next", returnUrl);return loginUrl.toString();}async exchangeToken(guid: string): Promise<{ jwt: string; user: ThreePMUser }> {if (!this.config.clientSecret) {throw new Error("clientSecret is required for token exchange");}const response = await fetch(`${this.config.idpUrl}/api/exchange-token`, {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify({guid,clientId: this.config.clientId,clientSecret: this.config.clientSecret,}),});const result = await response.json();if (result.error || !result.data) {throw new Error(result.error || "Token exchange failed");}return result.data;}}export const auth = new ThreePMAuth({idpUrl: process.env.IDP_URL!,clientId: process.env.CLIENT_ID!,clientSecret: process.env.CLIENT_SECRET!,});
3. Create Auth Utilities (lib/auth.ts)
import { cookies } from "next/headers";const IDP_URL = process.env.IDP_URL!;const CLIENT_ID = process.env.CLIENT_ID!;const CLIENT_SECRET = process.env.CLIENT_SECRET!;const SESSION_COOKIE = "session";export interface UserSession {id: string;email: string;firstName: string;lastName: string;profilePicUrl?: string;}// Verify token via 3PM Auth API (JWT secret is never shared)export async function getSession(): Promise<UserSession | null> {const cookieStore = await cookies();const token = cookieStore.get(SESSION_COOKIE);if (!token) return null;try {const response = await fetch(`${IDP_URL}/api/verify-token`, {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify({token: token.value,clientId: CLIENT_ID,clientSecret: CLIENT_SECRET,}),});const result = await response.json();if (result.error || !result.data?.valid) {return null;}return result.data.user;} catch {return null;}}export async function setSession(jwt: string): Promise<void> {const cookieStore = await cookies();cookieStore.set(SESSION_COOKIE, jwt, {httpOnly: true,secure: process.env.NODE_ENV === "production",sameSite: "lax",maxAge: 60 * 60 * 24,path: "/",});}export async function clearSession(): Promise<void> {const cookieStore = await cookies();cookieStore.delete(SESSION_COOKIE);}
4. Create Callback Route (app/api/auth/callback/route.ts)
import { NextRequest, NextResponse } from "next/server";import { auth } from "@/lib/3pm-auth";import { setSession } from "@/lib/auth";export async function GET(request: NextRequest) {const searchParams = request.nextUrl.searchParams;const guid = searchParams.get("guid");const returnUrl = searchParams.get("returnUrl") || "/dashboard";if (!guid) {return NextResponse.redirect(new URL("/login?error=missing_token", request.url));}try {const { jwt } = await auth.exchangeToken(guid);await setSession(jwt);const response = NextResponse.redirect(new URL(returnUrl, request.url));response.cookies.set("session", jwt, {httpOnly: true,secure: process.env.NODE_ENV === "production",sameSite: "lax",maxAge: 60 * 60 * 24,path: "/",});return response;} catch (error) {console.error("Auth callback error:", error);return NextResponse.redirect(new URL("/login?error=auth_failed", request.url));}}
5. Create Middleware (middleware.ts)
The middleware checks for session cookie presence and redirects to login if missing. Actual token verification happens in Server Components via the getSession() function.
import { NextResponse } from "next/server";import type { NextRequest } from "next/server";const IDP_URL = process.env.IDP_URL!;const CLIENT_ID = process.env.CLIENT_ID!;const protectedRoutes = ["/dashboard", "/settings", "/profile"];export async function middleware(request: NextRequest) {const { pathname } = request.nextUrl;const sessionCookie = request.cookies.get("session");// Check if accessing protected route without session cookieif (protectedRoutes.some((route) => pathname.startsWith(route))) {if (!sessionCookie) {// No session cookie, redirect to IdP loginconst callbackUrl = new URL("/api/auth/callback", request.url);callbackUrl.searchParams.set("returnUrl", pathname);const loginUrl = new URL("/authorize", IDP_URL);loginUrl.searchParams.set("clientId", CLIENT_ID);loginUrl.searchParams.set("next", callbackUrl.toString());return NextResponse.redirect(loginUrl);}// Cookie exists - actual token verification happens in getSession()}return NextResponse.next();}export const config = {matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],};
Token Verification
To verify a JWT token, your server should call the /api/verify-token endpoint. This ensures centralized security and allows 3PM Auth to revoke tokens if needed.
Node.js / Express Example
const axios = require("axios");const { IDP_URL, CLIENT_ID, CLIENT_SECRET } = process.env;async function verifyToken(token) {try {const response = await axios.post(`${IDP_URL}/api/verify-token`, {token,clientId: CLIENT_ID,clientSecret: CLIENT_SECRET,});const { data, error } = response.data;if (error || !data?.valid) {return null;}return data.user;} catch (error) {console.error("Token verification failed:", error.message);return null;}}// Express middlewareasync function requireAuth(req, res, next) {const token = req.cookies.session;if (!token) {return res.status(401).json({ error: "Authentication required" });}const user = await verifyToken(token);if (!user) {return res.status(401).json({ error: "Invalid or expired session" });}req.user = user;next();}module.exports = { verifyToken, requireAuth };
Next.js Example
const IDP_URL = process.env.IDP_URL!;const CLIENT_ID = process.env.CLIENT_ID!;const CLIENT_SECRET = process.env.CLIENT_SECRET!;export interface User {id: string;email: string;firstName: string;lastName: string;profilePicUrl?: string;}export async function verifyToken(token: string): Promise<User | null> {try {const response = await fetch(`${IDP_URL}/api/verify-token`, {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify({token,clientId: CLIENT_ID,clientSecret: CLIENT_SECRET,}),});const result = await response.json();if (result.error || !result.data?.valid) {return null;}return result.data.user;} catch {return null;}}
Python Example
import osimport requestsIDP_URL = os.environ['IDP_URL']CLIENT_ID = os.environ['CLIENT_ID']CLIENT_SECRET = os.environ['CLIENT_SECRET']def verify_token(token: str) -> dict | None:try:response = requests.post(f'{IDP_URL}/api/verify-token',json={'token': token,'clientId': CLIENT_ID,'clientSecret': CLIENT_SECRET,})result = response.json()if result.get('error') or not result.get('data', {}).get('valid'):return Nonereturn result['data']['user']except Exception as e:print(f'Token verification failed: {e}')return None
JWT Token Structure
For reference, here's what the JWT payload contains (decoded):
{"userId": "507f1f77bcf86cd799439011","email": "user@example.com","firstName": "John","lastName": "Doe","profilePicUrl": "https://example.com/avatar.jpg","iat": 1699999999,"exp": 1700086399,"iss": "3pm-auth-idp"}
Tenant Authentication
For tenant-based applications, the JWT token includes additional tenant context. Use this information to authorize tenant-scoped operations.
Tenant JWT Structure
{"userId": "507f1f77bcf86cd799439011","email": "user@example.com","firstName": "John","lastName": "Doe","tenantId": "60d5ecf4f1b5a23456789012","tenantRole": "admin","iat": 1699999999,"exp": 1700086399,"iss": "3pm-auth-idp"}
Checking Tenant Access
async function requireTenantAuth(req, res, next) {const token = req.cookies.session;if (!token) {return res.status(401).json({ error: "Authentication required" });}// Verify token via 3PM Auth APIconst response = await axios.post(`${IDP_URL}/api/verify-token`, {token,clientId: CLIENT_ID,clientSecret: CLIENT_SECRET,});const { data, error } = response.data;if (error || !data?.valid) {return res.status(401).json({ error: "Invalid session" });}// Check tenant membership for tenant-based appsif (!data.user.tenantId) {return res.status(403).json({ error: "Tenant membership required" });}req.user = data.user;req.tenantId = data.user.tenantId;req.tenantRole = data.user.tenantRole;next();}
Role-Based Authorization
// Check minimum rolefunction requireMinRole(minRole) {const roleHierarchy = { member: 0, admin: 1, owner: 2 };return (req, res, next) => {if (roleHierarchy[req.tenantRole] < roleHierarchy[minRole]) {return res.status(403).json({ error: "Insufficient permissions" });}next();};}// Usageapp.post("/api/tenant/invite",requireTenantAuth,requireMinRole("admin"),handleInvite);app.put("/api/tenant/settings",requireTenantAuth,requireMinRole("owner"),handleSettings);
Error Handling
Common Errors
| Error | Cause | Solution |
|---|---|---|
| Invalid or expired token | GUID expired (>60s) or already used | User must re-authenticate |
| Invalid client credentials | Wrong clientId or clientSecret | Verify credentials in dashboard |
| Redirect URL not allowed | Callback URL not in allowed list | Add URL to application settings |
| Missing required fields | API request missing parameters | Check request body/params |
| Tenant membership required | User not in any tenant | Direct user to join/create tenant |
| Insufficient permissions | User role too low for action | Check role requirements (owner/admin/member) |
| Invitation expired | Invite link older than 7 days | Request new invitation from admin |
Example Error Handler
async function handleAuth(guid) {try {const result = await auth.exchangeToken(guid);return result;} catch (error) {if (error.message.includes("expired")) {// Token expired, redirect to loginwindow.location.href = "/login?error=session_expired";} else if (error.message.includes("credentials")) {// Configuration errorconsole.error("Auth configuration error");} else {// Generic errorconsole.error("Auth error:", error);}throw error;}}
Troubleshooting
Use this troubleshooting guide to diagnose and resolve common integration issues.
Quick Diagnosis
┌─────────────────────────────────────────────────────────────────────────────┐ │ TROUBLESHOOTING DECISION TREE │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ What's the issue? │ │ │ │ │ ├─→ Login not working │ │ │ └─→ Check: Is /authorize URL correct? → See "Redirect Issues" │ │ │ │ │ ├─→ "Invalid or expired token" error │ │ │ ├─→ GUID older than 60 seconds? → User must re-authenticate │ │ │ └─→ GUID already used? → Each GUID can only be exchanged once │ │ │ │ │ ├─→ Session cookie not set │ │ │ ├─→ Check CORS: credentials: true in fetch/axios? │ │ │ ├─→ Check sameSite: use "lax" not "strict" │ │ │ └─→ In dev: both apps using localhost? │ │ │ │ │ ├─→ "Invalid client credentials" │ │ │ ├─→ CLIENT_ID correct? │ │ │ └─→ CLIENT_SECRET correct? (check for extra spaces) │ │ │ │ │ ├─→ "Redirect URL not allowed" │ │ │ └─→ Add exact callback URL in dashboard (including port) │ │ │ │ │ ├─→ Token verification always fails │ │ │ ├─→ Sending token, not GUID? │ │ │ ├─→ IDP_URL accessible from server? │ │ │ └─→ Check network firewall rules │ │ │ │ │ ├─→ Tenant access denied │ │ │ ├─→ User member of a tenant? │ │ │ ├─→ Tenant subscribed to app? │ │ │ └─→ User is owner/admin OR assigned to app? │ │ │ │ │ └─→ Invitation not received │ │ ├─→ Check spam folder │ │ ├─→ SendGrid API key configured? │ │ └─→ Invitation older than 7 days? (expired) │ │ │ └─────────────────────────────────────────────────────────────────────────────┘
Redirect Issues
Symptom: User isn't redirected to IdP or back to your app
Causes & Solutions:
- Verify
clientIdparameter in /authorize URL - Check that callback URL is registered in app settings
- Ensure
nextparameter is URL-encoded - Check browser console for JavaScript errors
CORS Issues
Symptom: API requests fail with CORS errors
Causes & Solutions:
- Add your domain to
CORS_ALLOWED_ORIGINSon IdP - Use
credentials: truein fetch/axios - Ensure IdP returns proper CORS headers
- In development, use same localhost port or configure proxy
OAuth Provider Issues
Symptom: Google/Apple login fails
Causes & Solutions:
- Verify OAuth credentials in IdP environment variables
- Check OAuth callback URLs are registered with providers
- For Google: Ensure OAuth consent screen is configured
- For Apple: Check Service ID and key configuration
- Check IdP server logs for detailed error messages
Multi-Tenancy Issues
Symptom: "Tenant membership required" or access denied
Causes & Solutions:
- Check if user is a member of any tenant
- Verify tenant has subscribed to the application
- For members: ensure admin has assigned the app to them
- Note: Owners and admins have implicit access to all subscribed apps
- For new users, redirect to "create organization" flow
- Check JWT for
tenantId,tenantName,tenantSlug, andtenantRoleclaims
Frequently Asked Questions
How do I test locally with HTTPS?
For local development, HTTP is fine. In production, ensure all traffic uses HTTPS. For local HTTPS testing, use mkcert to generate trusted certificates.
Can I verify tokens locally instead of calling the API?
No. The JWT secret is never shared with client applications for security reasons. Always verify tokens via the /api/verify-token endpoint.
How long do sessions last?
Session duration is controlled by the SESSION_TIMEOUT_MINUTESenvironment variable on the IdP (default: 24 hours). Set to 0 for sessions that never expire.
Can users belong to multiple tenants?
Yes. Users can be members of multiple tenants. When authenticating, the JWT will contain the tenant context for the specific app they're accessing. Implement tenant switching in your app if needed.
What happens if a GUID expires?
GUIDs expire after 60 seconds. If the user takes too long, they'll need to re-authenticate. Make sure your callback handler processes the GUID immediately without page refreshes.
How do I handle token refresh?
When a token verification fails (expired), redirect the user back to the IdP via/authorize. If they still have a valid IdP session, they'll be redirected back with a new GUID without seeing a login screen.
Debug Checklist
Before contacting support, verify:
Best Practices
Security
- • Never expose CLIENT_SECRET on client-side
- • Always use HTTPS in production
- • Validate redirect URLs on both client and server
- • Set secure cookie flags (httpOnly, secure, sameSite)
- • Handle token expiration gracefully
Performance
- • Cache user data after initial fetch
- • Implement silent refresh for expiring tokens
- • Use loading states during authentication
- • Minimize API calls with proper state management
User Experience
- • Show loading indicators during auth flow
- • Preserve intended destination after login
- • Handle errors with user-friendly messages
- • Provide logout in accessible location
Mobile Apps
- • Use deep links for auth callback
- • Store tokens securely (Keychain/Keystore)
- • Exchange tokens via your backend
- • Handle app state changes during auth
Multi-Tenancy
- • Validate tenant access on every request
- • Use role hierarchy for authorization
- • Scope data by tenant to prevent leaks
- • Check app assignments before access
- • Handle invitation flows gracefully
- • Show role-appropriate UI
- • Audit tenant operations
- • Support tenant switching if needed
Last updated: January 2026 (v2.0 - Multi-Tenancy)