
Security News
The Hidden Blast Radius of the Axios Compromise
The Axios compromise shows how time-dependent dependency resolution makes exposure harder to detect and contain.
@imtbl/auth-next-server
Advanced tools
Immutable Auth.js v5 integration for Next.js - Server-side utilities
Server-side utilities for Immutable authentication with Auth.js v5 (NextAuth) in Next.js applications.
This package provides server-side authentication utilities for Next.js applications using the App Router. It integrates with Auth.js v5 to handle OAuth authentication with Immutable's identity provider.
Key features:
@imtbl/auth)For client-side components (provider, hooks, callback page), use @imtbl/auth-next-client.
npm install @imtbl/auth-next-server next-auth@5
# or
pnpm add @imtbl/auth-next-server next-auth@5
# or
yarn add @imtbl/auth-next-server next-auth@5
next >= 14.0.0next-auth >= 5.0.0-beta.25This package is compatible with both Next.js 14 and 15. It uses only standard APIs available in both versions:
next/server: NextRequest, NextResponse (middleware)next/navigation: redirect (Server Components)No Next.js 15-only APIs are used (e.g. async headers()/cookies(), unstable_after).
Create a file to configure Immutable authentication:
// lib/auth.ts
import NextAuth from "next-auth";
import { 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`,
}),
);
Create the Auth.js API route handler:
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
# .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
Policy: provide nothing → full sandbox; provide config → provide everything.
With no configuration, createAuthConfig() uses sandbox defaults:
clientId: sandbox (public Immutable client ID)redirectUri: from window.location.origin + '/callback' (path only on server)When providing config, pass clientId and redirectUri (and optionally audience, scope, authenticationDomain) to avoid conflicts.
// lib/auth.ts
import NextAuth from "next-auth";
import { createAuthConfig } from "@imtbl/auth-next-server";
// Zero config - only AUTH_SECRET required in .env
export const { handlers, auth, signIn, signOut } = NextAuth(createAuthConfig());
With partial overrides:
// With config - provide clientId and redirectUri
export const { handlers, auth, signIn, signOut } = NextAuth(
createAuthConfig({
clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
}),
);
Note: Default auth uses public Immutable client IDs. For production apps, use your own client ID from Immutable Hub.
createAuthConfig(config?)Creates an Auth.js v5 configuration object for Immutable authentication. Config is optional—when omitted, sensible defaults are used. Pass this to NextAuth() to create your auth instance.
import NextAuth from "next-auth";
import { createAuthConfig } from "@imtbl/auth-next-server";
// Zero config
const { handlers, auth, signIn, signOut } = NextAuth(createAuthConfig());
// Or with custom config
const { handlers, auth, signIn, signOut } = NextAuth(
createAuthConfig({
clientId: "your-client-id",
redirectUri: "https://your-app.com/callback",
// Optional
audience: "platform_api", // Default: "platform_api"
scope: "openid profile email offline_access transact", // Default scope
authenticationDomain: "https://auth.immutable.com", // Default domain
}),
);
| Option | Type | Required | Description |
|---|---|---|---|
clientId | string | Yes* | Your Immutable application client ID (*required when config is provided) |
redirectUri | string | Yes* | OAuth redirect URI (*required when config is provided) |
audience | string | No | OAuth audience (default: "platform_api") |
scope | string | No | OAuth scopes (default: "openid profile email offline_access transact") |
authenticationDomain | string | No | Auth domain (default: "https://auth.immutable.com") |
You can spread the config and add your own Auth.js options:
import NextAuth from "next-auth";
import { createAuthConfig } from "@imtbl/auth-next-server";
const baseConfig = createAuthConfig({
clientId: "your-client-id",
redirectUri: "https://your-app.com/callback",
});
export const { handlers, auth, signIn, signOut } = NextAuth({
...baseConfig,
// Auth.js options
secret: process.env.AUTH_SECRET,
trustHost: true,
basePath: "/api/auth/custom",
// Extend callbacks (be sure to call the base callbacks first)
callbacks: {
...baseConfig.callbacks,
async jwt(params) {
// Call base jwt callback first
const token = (await baseConfig.callbacks?.jwt?.(params)) ?? params.token;
// Add your custom logic
return token;
},
async session(params) {
// Call base session callback first
const session =
(await baseConfig.callbacks?.session?.(params)) ?? params.session;
// Add your custom logic
return session;
},
},
});
This package provides several utilities for handling authentication in Server Components. Choose the right one based on your needs:
| Utility | Use Case | Data Fetching | Error Handling |
|---|---|---|---|
getAuthProps | Pass auth state to client, fetch data client-side | No | Manual |
getAuthenticatedData | SSR data fetching with client fallback | Yes | Manual |
createProtectedFetchers | Multiple pages with same error handling | Optional | Centralized |
getValidSession | Custom logic for each auth state | No | Manual (detailed) |
getAuthProps(auth)Use case: You want to pass authentication state to a Client Component but handle data fetching entirely on the client side. This is the simplest approach when your page doesn't need SSR data fetching.
When to use:
// app/dashboard/page.tsx
// Use case: Dashboard that fetches data client-side with loading states
import { auth } from "@/lib/auth";
import { getAuthProps } from "@imtbl/auth-next-server";
import { redirect } from "next/navigation";
import { DashboardClient } from "./DashboardClient";
export default async function DashboardPage() {
const authProps = await getAuthProps(auth);
if (authProps.authError) {
redirect("/login");
}
// DashboardClient will fetch its own data using useImmutableSession().getUser()
return <DashboardClient {...authProps} />;
}
getAuthenticatedData(auth, fetcher)Use case: You want to fetch data server-side for faster initial page loads (SSR), but gracefully fall back to client-side fetching when the token is expired.
When to use:
How it works:
ssr: truessr: false, client refreshes and fetchesuseHydratedData hook on the client for seamless handling// app/profile/page.tsx
// Use case: Profile page with SSR for fast initial load
import { auth } from "@/lib/auth";
import { getAuthenticatedData } from "@imtbl/auth-next-server";
import { redirect } from "next/navigation";
import { ProfileClient } from "./ProfileClient";
async function fetchUserProfile(accessToken: string) {
const response = await fetch("https://api.immutable.com/v1/user/profile", {
headers: { Authorization: `Bearer ${accessToken}` },
});
return response.json();
}
export default async function ProfilePage() {
const result = await getAuthenticatedData(auth, fetchUserProfile);
if (result.authError) {
redirect("/login");
}
// ProfileClient uses useHydratedData() to handle both SSR data and client fallback
return <ProfileClient {...result} />;
}
createProtectedFetchers(auth, onAuthError)Use case: You have multiple protected pages and want to define auth error handling once, rather than repeating if (authError) redirect(...) on every page.
When to use:
How it works:
getAuthProps and getData functions in your pages// lib/protected.ts
// Use case: Centralized auth error handling for all protected pages
import { auth } from "@/lib/auth";
import { createProtectedFetchers } from "@imtbl/auth-next-server";
import { redirect } from "next/navigation";
// Define once: what happens on auth errors across all protected pages
export const { getAuthProps, getData } = createProtectedFetchers(
auth,
(error) => {
// This runs automatically when there's an auth error (e.g., RefreshTokenError)
redirect(`/login?error=${error}`);
},
);
// app/dashboard/page.tsx
// Use case: Protected page without manual error checking
import { getData } from "@/lib/protected";
export default async function DashboardPage() {
// No need to check authError - it's handled by createProtectedFetchers
const result = await getData(async (token) => {
return fetchDashboardData(token);
});
return <DashboardClient {...result} />;
}
// app/settings/page.tsx
// Use case: Another protected page - same clean pattern
import { getAuthProps } from "@/lib/protected";
export default async function SettingsPage() {
// No need to check authError here either
const authProps = await getAuthProps();
return <SettingsClient {...authProps} />;
}
getValidSession(auth)Use case: You need fine-grained control over different authentication states and want to handle each case with custom logic.
When to use:
// app/account/page.tsx
// Use case: Page that shows completely different content based on auth state
import { auth } from "@/lib/auth";
import { getValidSession } from "@imtbl/auth-next-server";
export default async function AccountPage() {
const result = await getValidSession(auth);
switch (result.status) {
case "authenticated":
// Full access - render the complete account page with SSR data
const userData = await fetchUserData(result.session.accessToken);
return <FullAccountPage user={userData} />;
case "token_expired":
// Token expired but user has session - show skeleton, let client refresh
// This avoids a flash of "please login" for users who are actually logged in
return <AccountPageSkeleton session={result.session} />;
case "unauthenticated":
// No session at all - show login prompt or redirect
return <LoginPrompt message="Sign in to view your account" />;
case "error":
// Auth system error (e.g., refresh token revoked) - needs re-login
return <AuthErrorPage error={result.error} />;
}
}
createAuthMiddleware(auth, options)Use case: Protect entire sections of your app at the routing level, before pages even render. This is the most efficient way to block unauthenticated access.
When to use:
/dashboard/*, /settings/*)When NOT to use:
// middleware.ts
// Use case: Protect all dashboard and settings routes at the edge
import { createAuthMiddleware } from "@imtbl/auth-next-server";
import { auth } from "@/lib/auth";
export default createAuthMiddleware(auth, {
loginUrl: "/login",
// These paths skip authentication entirely
publicPaths: ["/", "/about", "/api/public", "/pricing"],
});
// Only run middleware on these paths (Next.js config)
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
| Option | Type | Description |
|---|---|---|
loginUrl | string | URL to redirect unauthenticated users (default: "/login") |
protectedPaths | (string | RegExp)[] | Paths that require authentication |
publicPaths | (string | RegExp)[] | Paths that skip authentication (takes precedence) |
withAuth(auth, handler)Use case: Protect individual API Route Handlers or Server Actions. Ensures the handler only runs for authenticated users.
When to use:
// app/api/user/inventory/route.ts
// Use case: API endpoint that returns user's inventory - must be authenticated
import { auth } from "@/lib/auth";
import { withAuth } from "@imtbl/auth-next-server";
import { NextResponse } from "next/server";
export const GET = withAuth(auth, async (session, request) => {
// session is guaranteed to exist - handler won't run if unauthenticated
const inventory = await fetchUserInventory(session.accessToken);
return NextResponse.json(inventory);
});
// app/actions/transfer.ts
// Use case: Server Action for transferring assets - requires authentication
"use server";
import { auth } from "@/lib/auth";
import { withAuth } from "@imtbl/auth-next-server";
export const transferAsset = withAuth(
auth,
async (session, formData: FormData) => {
const assetId = formData.get("assetId") as string;
const toAddress = formData.get("toAddress") as string;
// Use session.user.sub to identify the sender
// Use session.accessToken to call Immutable APIs
const result = await executeTransfer({
from: session.user.sub,
to: toAddress,
assetId,
accessToken: session.accessToken,
});
return result;
},
);
The package augments the Auth.js Session type with Immutable-specific fields:
interface Session {
user: {
sub: string; // Immutable user ID
email?: string;
nickname?: string;
};
accessToken: string;
refreshToken?: string;
idToken?: string; // Only present transiently after sign-in or token refresh (not stored in cookie)
accessTokenExpires: number;
zkEvm?: {
ethAddress: string;
userAdminAddress: string;
};
error?: string; // "TokenExpired" or "RefreshTokenError"
}
Note: The
idTokenis not stored in the session cookie. It is stripped by a customjwt.encodeto keep cookie size under CDN header limits. TheidTokenis only present in the session response transiently after sign-in or token refresh. On the client,@imtbl/auth-next-clientautomatically persists it inlocalStorageso that wallet operations (viagetUser()) can always access it. All data extracted from the idToken (nickname,zkEvm) remains in the cookie as separate fields.
The jwt callback automatically refreshes tokens when the access token expires. This happens transparently during any session access (page load, API call, etc.).
After operations that update the user's profile on the identity provider (e.g., zkEVM registration), you may need to force a token refresh to get the updated claims.
The getUser function from @imtbl/auth-next-client supports this:
import { useImmutableSession } from "@imtbl/auth-next-client";
function MyComponent() {
const { getUser } = useImmutableSession();
const handleRegistration = async () => {
// After zkEVM registration completes...
// Force refresh to get updated zkEvm claims from IDP
const freshUser = await getUser(true);
console.log("Updated zkEvm:", freshUser?.zkEvm);
};
}
When forceRefresh is triggered:
update({ forceRefresh: true }) via NextAuthjwt callback detects trigger === 'update' with forceRefresh: truezkEvm) are extracted from the new ID tokenThe package also exports utilities for manual token handling:
import {
isTokenExpired, // Check if access token is expired
refreshAccessToken, // Manually refresh tokens
extractZkEvmFromIdToken, // Extract zkEvm claims from ID token
} from "@imtbl/auth-next-server";
The session may contain an error field indicating authentication issues:
| Error | Description | Recommended Action |
|---|---|---|
"TokenExpired" | Access token expired, refresh token may be valid | Let client refresh via @imtbl/auth-next-client |
"RefreshTokenError" | Refresh token invalid/expired | Redirect to login |
All types are exported for use in your application:
import type {
ImmutableAuthConfig,
ImmutableTokenData,
ImmutableUser,
ZkEvmUser,
AuthProps,
AuthPropsWithData,
ProtectedAuthProps,
ProtectedAuthPropsWithData,
ValidSessionResult,
} from "@imtbl/auth-next-server";
@imtbl/auth-next-client - Client-side components and hooks@imtbl/auth - Core authentication libraryApache-2.0
FAQs
Immutable Auth.js v5 integration for Next.js - Server-side utilities
We found that @imtbl/auth-next-server demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 4 open source maintainers collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
The Axios compromise shows how time-dependent dependency resolution makes exposure harder to detect and contain.

Research
A supply chain attack on Axios introduced a malicious dependency, plain-crypto-js@4.2.1, published minutes earlier and absent from the project’s GitHub releases.

Research
Malicious versions of the Telnyx Python SDK on PyPI delivered credential-stealing malware via a multi-stage supply chain attack.