Documentation

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

IdPIdentity Provider - The 3PM Auth server
GUIDShort-lived token (60s TTL) used in the handshake
JWTJSON Web Token used for session management
Client IDYour app's unique identifier from the dashboard
Client SecretYour app's secret key (keep server-side only!)
TenantAn organization/workspace for B2B multi-tenant apps
Tenant RoleUser's role in a tenant (owner, admin, member)

Quick Start

1

Register Your Application

Go to the dashboard and create a new application. Configure your allowed callback URLs.

2

Save Your Credentials

Copy your Client ID and Client Secret. Store them securely in environment variables.

.envenv
# 3PM Auth Configuration
IDP_URL=https://idp.3pm.app
CLIENT_ID=3pm_xxxxxxxxxxxxxxxx
CLIENT_SECRET=3pm_secret_xxxxxxxxxxxxxxxxxxxxxxxx
Security: Never expose CLIENT_SECRET on the client-side. Use it only in server-side code.
3

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:

.env.local (Your App)env
# 3PM Auth Configuration (Client App)
IDP_URL=https://idp.3pm.app
CLIENT_ID=3pm_xxxxxxxxxxxxxxxx
CLIENT_SECRET=3pm_secret_xxxxxxxxxxxxxxxxxxxxxxxx
# Your app's public URL
NEXT_PUBLIC_APP_URL=http://localhost:3000
Security: Never expose 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:

VariableRequiredDescription
MONGODB_URIYesMongoDB connection string
MONGODB_DB_NAMEYesDatabase name (e.g., 3pm-auth)
JWT_SECRETYesSecret for signing JWTs (64+ chars)
ADMIN_SETUP_SECRETYesSecret for initial admin setup
NEXT_PUBLIC_APP_URLYesPublic URL of the IdP (with https://)
SENDGRID_API_KEYFor Email OTPSendGrid API key for email delivery
TWILIO_ACCOUNT_SIDFor SMS OTPTwilio account SID
GOOGLE_CLIENT_IDFor Google OAuthGoogle OAuth client ID
APPLE_CLIENT_IDFor Apple OAuthApple Sign In service ID
SESSION_TIMEOUT_MINUTESNoGlobal session timeout (default: 1440 = 24h)
CORS_ALLOWED_ORIGINSNoComma-separated allowed origins

Generating Secure Secrets

Use these commands to generate cryptographically secure secrets:

Terminalbash
# Generate JWT secret (64+ characters)
openssl rand -base64 64
# Generate admin setup secret
openssl rand -hex 32

Production Deployment Checklist

JWT_SECRET set to unique 64+ char value
ADMIN_SETUP_SECRET stored securely
HTTPS enforced for all traffic
MongoDB uses authentication + TLS
CORS_ALLOWED_ORIGINS properly restricted
All OAuth secrets configured for production
Firewall rules restrict database access
Health endpoint monitored (/api/health)
API Specification: Download our OpenAPI/Swagger specification for all API endpoints.

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. 1.User accesses a protected page on your app → Your app detects no session
  2. 2.Redirect to IdP: /authorize?clientId=xxx&next=callback-url
  3. 3.User authenticates using email, mobile, Google, or Apple
  4. 4.IdP redirects back: callback-url?guid=xxxxxxxx
  5. 5.Your server exchanges GUID for JWT via POST /api/exchange-token
  6. 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
}
Important: The registration token is valid for 10 minutes after OTP verification.

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.

Example: Detecting New Usersjavascript
// After successful token exchange
const { jwt, user } = await auth.exchangeToken(guid);
// Check if profile is incomplete (new OTP user)
if (!user.firstName || user.firstName.trim() === "") {
// New user - redirect to onboarding
navigate("/onboarding", {
state: { user, originalDestination: returnUrl }
});
} else {
// Existing user - normal flow
navigate(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

OwnerFull control - settings, subscriptions, all members
AdminInvite members, assign apps, manage roles, implicit access to all subscribed apps
MemberAccess explicitly assigned apps only, view member list

Tenant-Based Applications

When creating an app in the dashboard, enable "Tenant Based" to require tenant membership for authentication:

  1. 1.User must belong to a tenant
  2. 2.Tenant must subscribe to the application
  3. 3.Owners/admins have implicit access; members need explicit assignment
  4. 4.JWT includes tenantId, tenantName, tenantSlug, and tenantRole

Tenant Admin Dashboard

Access the tenant admin portal at /tenant-admin to manage your organization:

FeatureOwnerAdminMember
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"
}
Important: Always validate 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

POST/api/exchange-token

Exchange 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"
}
GET/authorize

Initiates the SSO handshake. Redirect users here to start authentication.

Query Parameters
ParameterRequiredDescription
clientIdYesYour application's Client ID
nextYesThe URL to redirect back to after authentication
Example
https://your-idp.com/authorize?clientId=3pm_xxx&next=https://yourapp.com/auth/callback
POST/api/verify-token

Verify a JWT token and get user data. Use this endpoint instead of verifying tokens locally—the JWT secret is never shared with client applications.

Recommended: Always verify tokens via this API to ensure centralized security control.
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"
}
POSTGET/api/signout

Sign out the user by clearing the IdP session cookie. Supports both GET (for redirect-based logout) and POST methods.

Query Parameters (GET)
ParameterRequiredDescription
redirectNoURL to redirect after sign out
Example
GET /api/signout?redirect=https://yourapp.com/logged-out
GET/api/health

Health 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" }
}
}
POST/api/setup

Initial 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.

One-time only: This endpoint fails if an admin already exists.
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
}
POST/api/init-db

Initialize 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
}
GET/api/invite/accept

Accept a tenant invitation. Called when a user clicks the invitation link in their email.

Query Parameters
ParameterRequiredDescription
tokenYesInvitation 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
GET/api/tenant/list

Fetch 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
}
POST/api/tenant/switch

Switch 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
StatusError
400Missing tenantId / Invalid tenant ID
401Not authenticated
403You are not a member of this tenant
404Tenant not found

Integration Guide

Next.js App Router (Recommended)

This guide uses the App Router with Server Components and Route Handlers.

No additional dependencies needed! Token verification is done via API call to 3PM Auth.

2. Create Auth SDK (lib/3pm-auth.ts)

lib/3pm-auth.tstypescript
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)

lib/auth.tstypescript
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)

app/api/auth/callback/route.tstypescript
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.

middleware.tstypescript
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 cookie
if (protectedRoutes.some((route) => pathname.startsWith(route))) {
if (!sessionCookie) {
// No session cookie, redirect to IdP login
const 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

Important: The JWT secret is never shared with client applications. Always verify tokens by calling the 3PM Auth API.

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

middleware/auth.jsjavascript
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 middleware
async 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

lib/auth.tstypescript
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 os
import requests
IDP_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 None
return 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

middleware/tenant.jsjavascript
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 API
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 res.status(401).json({ error: "Invalid session" });
}
// Check tenant membership for tenant-based apps
if (!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

middleware/roles.jsjavascript
// Check minimum role
function 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();
};
}
// Usage
app.post("/api/tenant/invite",
requireTenantAuth,
requireMinRole("admin"),
handleInvite
);
app.put("/api/tenant/settings",
requireTenantAuth,
requireMinRole("owner"),
handleSettings
);
Security Tip: Always validate tenant access and role on the server side. Never trust client-side role checks alone.

Error Handling

Common Errors

ErrorCauseSolution
Invalid or expired tokenGUID expired (>60s) or already usedUser must re-authenticate
Invalid client credentialsWrong clientId or clientSecretVerify credentials in dashboard
Redirect URL not allowedCallback URL not in allowed listAdd URL to application settings
Missing required fieldsAPI request missing parametersCheck request body/params
Tenant membership requiredUser not in any tenantDirect user to join/create tenant
Insufficient permissionsUser role too low for actionCheck role requirements (owner/admin/member)
Invitation expiredInvite link older than 7 daysRequest 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 login
window.location.href = "/login?error=session_expired";
} else if (error.message.includes("credentials")) {
// Configuration error
console.error("Auth configuration error");
} else {
// Generic error
console.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 clientId parameter in /authorize URL
  • Check that callback URL is registered in app settings
  • Ensure next parameter 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_ORIGINS on IdP
  • Use credentials: true in 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, and tenantRole claims

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:

Environment variables are correctly set
Callback URLs match exactly (including trailing slashes)
No extra spaces in credential values
Browser console shows no JavaScript errors
Server logs checked for detailed errors
IdP health endpoint returns 200

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

Need to integrate with external systems?

Use the Data API to sync users, tenants, and subscriptions with your CRM, analytics, or other platforms.

Last updated: January 2026 (v2.0 - Multi-Tenancy)