Skip to main content

Prerequisites

Create Passport Client

Set up your Passport client in Immutable Hub to get the credentials needed for authentication.

Create Passport Client

Step-by-step guide to creating a Passport client, configuring redirect URIs, and getting your Client ID
You’ll need:
  • Client ID from your Passport client in Hub
  • Publishable Key from your Hub project settings
These values are required to initialize Passport in the next section.

Passport Credentials Reference

FieldTypeDescription
Client IDProvided by HubUnique identifier for your application. Copy from Hub and use in SDK initialization.
Publishable KeyProvided by HubPublic key safe for client-side code. Copy from Hub project settings.
Application TypeYou configureSelect Web for TypeScript/web apps, Native for Unity/Unreal games.
Application NameYou configureIdentifier for your project inside Passport (e.g., “My Game”).
Redirect URIsYou configureWhere users land after successful authentication. Must exactly match redirectUri in your code. Examples: http://localhost:3000/redirect (web), mygame://callback (native).
Logout URIsYou configureWhere users land after logout. Must exactly match logoutRedirectUri in your code. Examples: http://localhost:3000/logout (web), mygame://logout (native).
Passport uses OpenID Connect (OIDC). Redirect URIs must be exact matches—wildcards aren’t supported for security reasons. Register multiple URIs for different environments (localhost, staging, production).
For complete details on client configuration, see Passport Clients in Hub.

Installation

Install the Immutable SDK for your platform to get started with Passport:

Initialize Passport

Create your auth configuration:
// lib/auth.ts
import { NextAuth, createAuthConfig } from "@imtbl/auth-next-server";

export const { handlers, auth, signIn, signOut } = NextAuth({
  ...createAuthConfig({
    clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
    redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
  }),
  secret: process.env.AUTH_SECRET,
  trustHost: true,
});
Create the API route handler:
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";

export const { GET, POST } = handlers;
Wrap your app with SessionProvider:
// app/layout.tsx
import { SessionProvider } from "next-auth/react";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SessionProvider>{children}</SessionProvider>
      </body>
    </html>
  );
}
Set environment variables:
# .env.local
NEXT_PUBLIC_IMMUTABLE_CLIENT_ID=your_client_id
NEXT_PUBLIC_BASE_URL=http://localhost:3000
AUTH_SECRET=your-secret-key-min-32-characters
For full setup details including server utilities and route protection, see the Next.js integration guide.

Login

The useLogin hook provides embedded, popup, and redirect login flows. All functions accept an optional config; when omitted, sandbox defaults are used.

Embedded Login

Shows an in-page modal for login method selection — no popup window required:
'use client';

import { useLogin, useImmutableSession, type LoginConfig } from '@imtbl/auth-next-client';

function LoginButton() {
  const { loginWithEmbedded, isLoggingIn } = useLogin();
  const { isAuthenticated } = useImmutableSession();

  const loginConfig: LoginConfig = {
    clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
    redirectUri: `${window.location.origin}/callback`,
    audience: 'platform_api',
    scope: 'openid profile email offline_access transact',
    authenticationDomain: 'https://auth.immutable.com',
  };

  if (isAuthenticated) return <p>Logged in</p>;

  return (
    <button onClick={() => loginWithEmbedded(loginConfig)} disabled={isLoggingIn}>
      {isLoggingIn ? 'Signing in...' : 'Login with Passport'}
    </button>
  );
}
const { loginWithPopup } = useLogin();

await loginWithPopup(loginConfig);

Redirect Login

const { loginWithRedirect } = useLogin();

// Navigates away from the page for OAuth authentication
await loginWithRedirect(loginConfig);

Direct Login Options

Skip the login method selection screen and go directly to a specific provider:
import { MarketingConsentStatus } from '@imtbl/auth-next-client';

const { loginWithPopup } = useLogin();

// Direct to Google login
await loginWithPopup(config, {
  directLoginOptions: {
    directLoginMethod: 'google',
    marketingConsentStatus: MarketingConsentStatus.OptedIn,
  },
});

// Direct to email login with marketing consent
await loginWithPopup(config, {
  directLoginOptions: {
    directLoginMethod: 'email',
    email: 'user@example.com',
    marketingConsentStatus: MarketingConsentStatus.OptedIn,
  },
});
OptionDescription
directLoginMethodThe authentication provider (email, google, or apple)
emailRequired when directLoginMethod is email
marketingConsentStatusMarketing consent (OptedIn or Unsubscribed)

Handle the Callback

On your redirect URI page, process the authentication callback:
// app/callback/page.tsx
'use client';

import { CallbackPage, type ImmutableAuthConfig } from '@imtbl/auth-next-client';

const config: ImmutableAuthConfig = {
  clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
  redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
  audience: 'platform_api',
  scope: 'openid profile email offline_access transact',
};

export default function Callback() {
  return (
    <CallbackPage
      config={config}
      loadingComponent={<p>Completing authentication...</p>}
    />
  );
}
The CallbackPage component handles both redirect and popup flows automatically. For popup logins, it communicates tokens back to the parent window and closes itself.

Get User Information

User Profile

'use client';

import { useImmutableSession } from '@imtbl/auth-next-client';

function UserProfile() {
  const { session, isAuthenticated } = useImmutableSession();

  if (!isAuthenticated) return null;

  return (
    <div>
      <p>User ID: {session?.user?.sub}</p>
      <p>Email: {session?.user?.email}</p>
      <p>Wallet: {session?.zkEvm?.ethAddress}</p>
    </div>
  );
}

Session Management

Check If Logged In

Always use isAuthenticated from useImmutableSession to determine if a user is logged in.
'use client';

import { useImmutableSession } from '@imtbl/auth-next-client';

function ProtectedContent() {
  const { isAuthenticated, isLoading } = useImmutableSession();

  if (isLoading) return <div>Loading...</div>;
  if (!isAuthenticated) return <div>Please log in</div>;

  return <div>Protected content</div>;
}
Do not use !!session or status === 'authenticated' to check auth state. A session object can exist with expired or invalid tokens, and status does not account for token-level errors like RefreshTokenError.
isAuthenticated validates all of the following:
  1. NextAuth reports 'authenticated' status
  2. The session object exists
  3. A valid access token is present in the session
  4. No session-level error exists (such as RefreshTokenError)
It also handles transient states gracefully — during session refetches (window focus) or manual refreshes (after wallet registration via getUser(true)), isAuthenticated remains true if the user was previously authenticated, preventing UI flicker.
// Correct
const { isAuthenticated } = useImmutableSession();
if (!isAuthenticated) return <div>Please log in</div>;

// Incorrect -- session can exist with expired/invalid tokens
const { session } = useImmutableSession();
if (!session) return <div>Please log in</div>;

// Incorrect -- status doesn't account for token errors
const { status } = useImmutableSession();
if (status !== "authenticated") return <div>Please log in</div>;

Get Access Token

For authenticated API calls to your backend:
getAccessToken() returns a guaranteed-fresh access token. If the current token is valid it returns immediately; if expired, it triggers a server-side refresh and blocks until the fresh token is available. Multiple concurrent calls share a single refresh request.

SWR Fetcher

import useSWR from 'swr';
import { useImmutableSession } from '@imtbl/auth-next-client';

function useProfile() {
  const { getAccessToken, isAuthenticated } = useImmutableSession();

  return useSWR(
    isAuthenticated ? '/passport-profile/v1/profile' : null,
    async (path) => {
      const token = await getAccessToken();
      const res = await fetch(path, {
        headers: { Authorization: `Bearer ${token}` },
      });
      return res.json();
    },
  );
}

Event Handler

import { useImmutableSession } from '@imtbl/auth-next-client';

function ClaimRewardButton({ questId }: { questId: string }) {
  const { getAccessToken } = useImmutableSession();

  const handleClaim = async () => {
    const token = await getAccessToken();
    await fetch('/v1/quests/claim', {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}` },
      body: JSON.stringify({ questId }),
    });
  };

  return <button onClick={handleClaim}>Claim</button>;
}

Periodic Polling

import useSWR from 'swr';
import { useImmutableSession } from '@imtbl/auth-next-client';

function ActivityFeed() {
  const { getAccessToken, isAuthenticated } = useImmutableSession();

  return useSWR(
    isAuthenticated ? '/v1/activities' : null,
    async (path) => {
      const token = await getAccessToken();
      const res = await fetch(path, {
        headers: { Authorization: `Bearer ${token}` },
      });
      return res.json();
    },
    { refreshInterval: 10000 },
  );
}

Token Refresh

Automatic Refresh

The server-side JWT callback automatically refreshes tokens when the access token expires. This happens transparently during any session access.

Force Refresh

After operations that update user claims on the identity provider (such as zkEVM wallet registration), force a token refresh to get the updated data:
const { getUser } = useImmutableSession();

async function handleRegistration() {
  // ... after zkEVM registration completes

  const user = await getUser(true);
  console.log("Updated zkEvm:", user?.zkEvm);
}

Get ID Token

The ID token contains user identity claims:
The ID token is not stored in the session cookie (to stay within CDN header size limits). Use getUser() to access it — the client persists it in localStorage automatically.
'use client';

import { useImmutableSession } from '@imtbl/auth-next-client';

function GetIdToken() {
  const { getUser } = useImmutableSession();

  async function handleGetToken() {
    const user = await getUser();
    console.log('ID Token:', user?.idToken);
  }

  return <button onClick={handleGetToken}>Get ID Token</button>;
}

Logout

The useLogout hook performs federated logout — it clears both the local NextAuth session and the upstream Immutable/Auth0 session by redirecting to the logout endpoint. This is important when using social logins like Google: without federated logout, the auth server caches the social session, so users clicking “Login” again would be automatically logged in with the same account instead of being prompted to choose.
'use client';

import { useLogout, useImmutableSession } from '@imtbl/auth-next-client';

function LogoutButton() {
  const { logout, isLoggingOut, error } = useLogout();
  const { isAuthenticated } = useImmutableSession();

  if (!isAuthenticated) return null;

  return (
    <>
      <button onClick={() => logout()} disabled={isLoggingOut}>
        {isLoggingOut ? 'Signing out...' : 'Sign Out'}
      </button>
      {error && <p>{error}</p>}
    </>
  );
}
To customize the logout redirect URI:
const { logout } = useLogout();

await logout({
  clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
  logoutRedirectUri: `${window.location.origin}/logged-out`,
});

Error Handling

Common Logout Issues:
ErrorCauseSolution
Logout callback timeoutSilent logout page didn’t loadVerify logoutRedirectUri is accessible
Wallet still connectedCleanup order wrongDisconnect wallet before passport.logout()
Session persists (Unreal)Soft logout usedSet DoHardLogout: true
Example (TypeScript):
try {
  await passportInstance.logout();
} catch (error) {
  console.error('Logout failed:', error);
  // Fallback: clear local state anyway
  clearUserState();
}

Backend JWT Validation

Validate Passport JWTs on your server to secure API endpoints.

Node.js with jose

import { createRemoteJWKSet, jwtVerify } from 'jose';

// Create JWKS client (cache this)
const JWKS = createRemoteJWKSet(
  new URL('https://auth.immutable.com/.well-known/jwks.json')
);

export async function validatePassportToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: 'https://auth.immutable.com/',
      audience: 'platform_api',
    });
    
    return {
      valid: true,
      userId: payload.sub,
      email: payload.email,
    };
  } catch (error) {
    return { valid: false, error: error.message };
  }
}

Express Middleware

import { Request, Response, NextFunction } from 'express';

export async function requireAuth(
  req: Request, 
  res: Response, 
  next: NextFunction
) {
  const authHeader = req.headers.authorization;
  
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }
  
  const token = authHeader.slice(7);
  const result = await validatePassportToken(token);
  
  if (!result.valid) {
    return res.status(401).json({ error: 'Invalid token' });
  }
  
  req.user = { id: result.userId, email: result.email };
  next();
}

// Usage
app.get('/api/inventory', requireAuth, (req, res) => {
  const userId = req.user.id;
  // Fetch inventory for this user
});

JWT Claims Reference

ClaimTypeDescription
substringUnique Passport user ID
issstringAlways https://auth.immutable.com/
audstringYour audience (e.g., platform_api)
expnumberExpiration timestamp
iatnumberIssued at timestamp
emailstringUser’s email (if scope granted)
email_verifiedbooleanWhether email is verified

Error Handling

Handle authentication errors gracefully to provide better user experience:
Check the session error field for token-level issues and use try/catch around getAccessToken():
'use client';

import { useImmutableSession } from '@imtbl/auth-next-client';
import { signOut } from 'next-auth/react';

function ProtectedContent() {
  const { session, isAuthenticated, getAccessToken } = useImmutableSession();

  if (session?.error === 'RefreshTokenError') {
    return (
      <div>
        <p>Your session has expired. Please sign in again.</p>
        <button onClick={() => signOut()}>Sign Out</button>
      </div>
    );
  }

  if (!isAuthenticated) {
    return <p>Please sign in to continue.</p>;
  }

  const handleFetch = async () => {
    try {
      const token = await getAccessToken();
      await fetch('/api/data', {
        headers: { Authorization: `Bearer ${token}` },
      });
    } catch (error) {
      console.error('Failed to get access token:', error);
    }
  };

  return <button onClick={handleFetch}>Fetch Data</button>;
}
Session ErrorDescriptionAction
"TokenExpired"Access token expiredHandled automatically by getAccessToken()
"RefreshTokenError"Refresh token invalidPrompt user to sign in again

Common Error Types

Error TypeDescriptionRecommended Action
AUTHENTICATION_ERRORLogin or authentication failedAsk user to try again or use different provider
USER_REGISTRATION_ERRORUser registration failedCheck if user already exists or retry
WALLET_CONNECTION_ERRORFailed to connect walletRetry connection or check network
INVALID_CONFIGURATIONInvalid Passport configurationVerify clientId and redirectUri

Next Steps