Noel
03/02/2026, 1:03 PMDevRev
03/02/2026, 1:03 PMauthMiddleware itself doesn't have built-in URL capture for post-authentication redirects, but you can implement this pattern using Next.js middleware customization:
import { authMiddleware } from '@descope/nextjs-sdk/server';
import { NextResponse } from 'next/server';
export default authMiddleware({
projectId: process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID,
redirectUrl: '/sign-in',
publicRoutes: ['/home', '/about'],
privateRoutes: ['/dashboard', '/profile'],
customMiddleware: (req) => {
// Capture the originally requested URL
if (!req.auth?.isAuthenticated) {
const originalUrl = req.nextUrl.pathname + req.nextUrl.search;
const signInUrl = new URL('/sign-in', req.url);
signInUrl.searchParams.set('returnTo', originalUrl);
return NextResponse.redirect(signInUrl);
}
}
});
Using URL Parameters on Sign-in Page
On your sign-in page, capture the returnTo parameter and pass it to your Descope component:
'use client';
import { Descope } from '@descope/nextjs-sdk/client';
import { useSearchParams, useRouter } from 'next/navigation';
export default function SignInPage() {
const searchParams = useSearchParams();
const router = useRouter();
const returnTo = searchParams.get('returnTo') || '/dashboard';
const handleSuccess = () => {
router.push(returnTo);
};
return (
<Descope
projectId={process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID}
flowId="sign-up-or-in"
onSuccess={handleSuccess}
/>
);
}
Alternative: Custom Hook Approach
Create a custom hook to manage authentication redirects:
// hooks/useAuthRedirect.ts
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';
export function useAuthRedirect() {
const router = useRouter();
const searchParams = useSearchParams();
const handleAuthSuccess = useCallback(() => {
const returnTo = searchParams.get('returnTo') || '/dashboard';
router.push(returnTo);
}, [router, searchParams]);
return { handleAuthSuccess };
}
The key is manually capturing the requested URL in your middleware and passing it as a query parameter to your authentication flow .
Next.js Route Protection | Descope DocumentationNoel
03/02/2026, 1:26 PMcustomMiddleware is never called. Looking at the MiddlewareOptions type in the nextjs library, this doesn't seem to be a valid option.DevRev
03/02/2026, 1:26 PMcustomMiddleware option in the Descope authMiddleware. Instead, you need to create your own middleware wrapper around Descope's auth logic.
Custom Middleware Wrapper
Create your own middleware that handles URL capture before delegating to Descope:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { session } from '@descope/nextjs-sdk/server';
export async function middleware(request: NextRequest) {
const { pathname, search } = request.nextUrl;
// Skip auth for public routes
const publicRoutes = ['/sign-in', '/about', '/'];
if (publicRoutes.includes(pathname)) {
return NextResponse.next();
}
try {
const sessionData = await session(request);
if (!sessionData?.isAuthenticated) {
const originalUrl = pathname + search;
const signInUrl = new URL('/sign-in', request.url);
signInUrl.searchParams.set('returnTo', originalUrl);
return NextResponse.redirect(signInUrl);
}
return NextResponse.next();
} catch (error) {
// Redirect to sign-in on auth error
const originalUrl = pathname + search;
const signInUrl = new URL('/sign-in', request.url);
signInUrl.searchParams.set('returnTo', originalUrl);
return NextResponse.redirect(signInUrl);
}
}
export const config = {
matcher: ['/((?!_next|._ .._).*)'],
};
Sign-in Page Implementation
Handle the redirect after successful authentication:
// app/sign-in/page.tsx
'use client';
import { Descope } from '@descope/nextjs-sdk/client';
import { useRouter, useSearchParams } from 'next/navigation';
export default function SignInPage() {
const router = useRouter();
const searchParams = useSearchParams();
const returnTo = searchParams.get('returnTo') || '/dashboard';
return (
<Descope
projectId={process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID!}
flowId="sign-up-or-in"
onSuccess={() => router.push(returnTo)}
/>
);
}
This approach gives you full control over the redirect logic while still leveraging Descope's session validation .
Next.js Route Protection | Descope DocumentationNoel
03/02/2026, 1:49 PMproxy.ts to the following:
import { NextRequest, NextResponse } from 'next/server';
import { session } from '@descope/nextjs-sdk/server';
export default async function middleware(request: NextRequest) {
const { pathname, search } = request.nextUrl;
// Skip auth for public routes
const publicRoutes = ['/sign-in', '/unauthorized'];
if (publicRoutes.includes(pathname)) {
return NextResponse.next();
}
try {
const sessionData = await session({
sessionCookieName: 'DS',
logLevel: 'warn',
projectId: process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID,
baseUrl: process.env.DESCOPE_BASE_URL || '<https://api.descope.com>', // API endpoint,
});
if (!sessionData?.token) {
const originalUrl = pathname + search;
const signInUrl = new URL('/sign-in', request.url);
signInUrl.searchParams.set('returnTo', originalUrl);
return NextResponse.redirect(signInUrl);
}
return NextResponse.next();
} catch {
const originalUrl = pathname + search;
const signInUrl = new URL('/sign-in', request.url);
signInUrl.searchParams.set('returnTo', originalUrl);
return NextResponse.redirect(signInUrl);
}
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|sw.js|icon|fonts/|docs/|manifest.json|.well-known/|).*)",
],
}
And app/sign-in/page.tsx to the following:
"use client"
import { ErrorBoundary } from '@/global/error-management/error-boundary';
import { logError } from '@/global/error-management/utils';
import { Descope } from '@descope/nextjs-sdk';
import { useRouter, useSearchParams } from 'next/navigation';
export default function SignInPage() {
const searchParams = useSearchParams();
const router = useRouter()
const returnTo = searchParams.get('returnTo') || '/home';
console.log(`[DEBUG] returnTo: ${returnTo}`)
return <ErrorBoundary log="Descope Sign-in" onError={() => router.push("/unauthorized")}>
<Descope
flowId="sign-up-or-in-amaebetter-simplified-2fa"
theme="light"
onSuccess={() => {
router.push(returnTo)
}}
onError={(error: unknown) => {
logError("descope error", error, "info")
router.push("/unauthorized")
}}
/>
</ErrorBoundary>
}
searchParams.get('returnTo') is always emptyDevRev
03/02/2026, 1:49 PMuseSearchParams() in client components can be empty during initial render due to SSR/hydration mismatch.
Solution: Wrap with Suspense
// app/sign-in/page.tsx
"use client"
import { Suspense } from 'react';
import { ErrorBoundary } from '@/global/error-management/error-boundary';
import { logError } from '@/global/error-management/utils';
import { Descope } from '@descope/nextjs-sdk';
import { useRouter, useSearchParams } from 'next/navigation';
function SignInForm() {
const searchParams = useSearchParams();
const router = useRouter();
const returnTo = searchParams.get('returnTo') || '/home';
console.log(`[DEBUG] returnTo: ${returnTo}`);
return (
<Descope
flowId="sign-up-or-in-amaebetter-simplified-2fa"
theme="light"
onSuccess={() => {
router.push(returnTo);
}}
onError={(error: unknown) => {
logError("descope error", error, "info");
router.push("/unauthorized");
}}
/>
);
}
export default function SignInPage() {
const router = useRouter();
return (
<ErrorBoundary log="Descope Sign-in" onError={() => router.push("/unauthorized")}>
<Suspense fallback={<div>Loading...</div>}>
<SignInForm />
</Suspense>
</ErrorBoundary>
);
}
Alternative: Use Effect Hook
"use client"
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export default function SignInPage() {
const [returnTo, setReturnTo] = useState('/home');
const searchParams = useSearchParams();
const router = useRouter();
useEffect(() => {
const returnParam = searchParams.get('returnTo');
if (returnParam) {
setReturnTo(returnParam);
}
}, [searchParams]);
return (
<Descope
flowId="sign-up-or-in-amaebetter-simplified-2fa"
theme="light"
onSuccess={() => router.push(returnTo)}
/>
);
}
The Suspense wrapper is the recommended approach as it properly handles the client-side search params hydration.Noel
03/02/2026, 1:57 PMDevRev
03/02/2026, 1:58 PM// app/sign-in/page.tsx
"use client"
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Descope } from '@descope/nextjs-sdk';
export default function SignInPage() {
const searchParams = useSearchParams();
const router = useRouter();
useEffect(() => {
// Debug what we're actually receiving
console.log('[DEBUG] Full URL:', window.location.href);
console.log('[DEBUG] Search params:', window.location.search);
console.log('[DEBUG] useSearchParams toString:', searchParams.toString());
console.log('[DEBUG] returnTo param:', searchParams.get('returnTo'));
}, [searchParams]);
const returnTo = searchParams.get('returnTo') || '/home';
return (
<Descope
flowId="sign-up-or-in-amaebetter-simplified-2fa"
theme="light"
onSuccess={() => router.push(returnTo)}
/>
);
}
Check Your Middleware Logic
Also verify your middleware is actually setting the parameter correctly:
// middleware.ts - add debug logging
const originalUrl = pathname + search;
const signInUrl = new URL('/sign-in', request.url);
signInUrl.searchParams.set('returnTo', originalUrl);
console.log('[MIDDLEWARE] Redirecting to:', signInUrl.href);
console.log('[MIDDLEWARE] Original URL was:', originalUrl);
return NextResponse.redirect(signInUrl);
Run this and check your browser's network tab to see the actual redirect URL being generated.Noel
03/02/2026, 3:18 PMDevRev
03/02/2026, 3:18 PMNoel
03/02/2026, 4:52 PMconst sessionData = await session(request);
if (!sessionData?.isAuthenticated)
The session function actually takes a SessionConfig argument, and sessionData don't include an isAuthenticated attribute.
How can I check if the user is authenticated in a proper and secure way? Looking at the implementation of authMiddleware, there is a validateJWT function in the node-sdk, but I can't find how to import it + I also need to check the refresh token).DevRev
03/02/2026, 4:52 PMisAuthenticated property. The correct pattern is to check for the presence of a valid token:
try {
const sessionData = await session({
sessionCookieName: 'DS',
logLevel: 'warn',
projectId: process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID,
baseUrl: process.env.DESCOPE_BASE_URL || '<https://api.descope.com>',
});
// Check if session token exists and is valid
if (!sessionData?.token) {
// Redirect to sign-in
const originalUrl = pathname + search;
const signInUrl = new URL('/sign-in', request.url);
signInUrl.searchParams.set('returnTo', originalUrl);
return NextResponse.redirect(signInUrl);
}
return NextResponse.next();
} catch {
// Session validation failed, redirect to sign-in
}
Alternative: Node SDK ValidateJWT
For direct token validation using the Node SDK (if you need more control), you can import and use validateJWT :
import DescopeClient from '@descope/node-sdk';
const descopeClient = DescopeClient({
projectId: process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID
});
// Extract token from cookie or header
const sessionToken = request.cookies.get('DS')?.value;
try {
const validatedSession = await descopeClient.validateJWT(sessionToken);
// Token is valid, user is authenticated
} catch {
// Invalid token, redirect to sign-in
}
The first approach (checking sessionData?.token) is recommended as it handles refresh tokens and other session management automatically .
Backend SDK Session Validation | Descope Documentation
Authenticating APIs With tRPC and Descope