Scopes
How a request is attributed to the admin panel or the website, and how to compose the proxy with your own.
A browser holding two sessions makes every REST call ambiguous. /api/customers/me is the same URL whether the admin panel asked or the website did, and the right answer differs.
Something has to attribute each request, and write the answer where the isolated strategies can read it.
With a role-split collection the ambiguity is sharper still: /api/users/me is one URL over one collection with two live sessions, told apart by nothing but which cookie the request carried.
The contract
One request header, x-payload-auth-scope, carrying one of two values:
| Value | Meaning |
|---|---|
admin | This request belongs to the admin panel. Isolated cookies stand down. |
frontend | This request belongs to the website. Cookies whose scopes include frontend may authenticate. |
| absent | Unattributed. adminSessionPriority decides. |
Two rules for whoever sets it:
Set it on the request Payload receives, not on the response. In Next that means NextResponse.next({ request: { headers } }).
Replace it, never merge it. A request arriving with its own x-payload-auth-scope must have that value overwritten or removed, so the value a strategy reads is always yours.
The scope picks which of the caller's own sessions to use. It is not an authorization boundary: forcing admin only makes fewer credentials count, and forcing frontend only honours a cookie the caller already holds. Do not read it inside your own access control.
Scopes govern isolated cookies only. A session on the shared ${cookiePrefix}-token is read by core's own strategy, which has never heard of scopes and authenticates every request whatever the header says. That is deliberate: it is what keeps the admin panel reachable, and why an editor browsing the website is still the editor there. On a role-split collection it means the staff half is not scoped at all, and only the website half is.
Absent is a state to use, not a failure to avoid. A proxy that cannot attribute a request should send no scope, and the plugin then answers by checking whether an admin session actually exists.
The default rule
createAuthScopeProxy implements this:
| Request | Scope |
|---|---|
Under adminRoute (/admin) | admin |
Under apiRoute (/api), same-origin Referer under adminRoute | admin |
Under apiRoute, any other Referer | frontend |
Under apiRoute, no usable Referer | absent |
| Anything else | frontend |
Admin pages and their server actions live under /admin, so those are unambiguous. REST calls are attributed by Referer, which same-origin browser fetches carry in full.
A Referer only says something about the admin panel when it belongs to the origin the API is served from. A frontend on another origin may have an /admin route of its own, and Sec-Fetch-Site is what separates the two: cross-site or same-site means the referring page is not yours, whatever its path looks like. Browsers set that header themselves and a page cannot forge it.
Installing the proxy
import { createAuthScopeProxy } from '@10x-media/dual-session/proxy'
export default createAuthScopeProxy()
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}Next 16 renamed middleware.ts to proxy.ts. Either name works on the version that expects it; the file sits next to app/.
The matcher must include /api
The widely copied default excludes it:
// Wrong for this plugin: REST calls never reach the proxy.
export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] }Use the one above instead.
Composing with an existing proxy
Next allows exactly one proxy file, so if you already have one, do not replace it. resolveAuthScope is a pure function with no next/server import, and is the intended integration point:
import { AUTH_SCOPE_HEADER, resolveAuthScope } from '@10x-media/dual-session/proxy'
import { type NextRequest, NextResponse } from 'next/server'
export default function proxy(request: NextRequest) {
const headers = new Headers(request.headers)
const scope = resolveAuthScope({
pathname: request.nextUrl.pathname,
referer: request.headers.get('Referer'),
secFetchSite: request.headers.get('Sec-Fetch-Site'),
})
if (scope) {
headers.set(AUTH_SCOPE_HEADER, scope)
} else {
headers.delete(AUTH_SCOPE_HEADER)
}
return NextResponse.next({ request: { headers } })
}resolveAuthScope returns undefined for a request it cannot attribute. Deleting the header in that branch is what keeps a client from sending its own.
Anything that returns its own NextResponse (next-intl, auth.js, a rate limiter) drops the header unless you thread it through request: { headers }. The symptom is subtle: no scope means adminSessionPriority decides, and frontend sessions are quietly ignored for as long as an admin session is alive.
Chaining onto a library's proxy usually means passing the modified request into it:
const intl = createMiddleware(routing)
export default function proxy(request: NextRequest) {
const headers = new Headers(request.headers)
const scope = resolveAuthScope({ ... })
if (scope) headers.set(AUTH_SCOPE_HEADER, scope)
else headers.delete(AUTH_SCOPE_HEADER)
return intl(new NextRequest(request, { headers }))
}Custom rules
createAuthScopeProxy takes a resolveScope that runs first; return undefined to fall through to the default rule.
export default createAuthScopeProxy({
resolveScope: (request) =>
request.nextUrl.pathname.startsWith('/preview') ? 'admin' : undefined,
})adminRoute and apiRoute follow your Payload routes config if you have changed them.
Per-collection scopes
A collection defaults to scopes: ['frontend']. Widen it when a collection should also authenticate admin-scoped requests:
dualSession({
collections: [{ slug: 'partners', scopes: ['frontend', 'admin'] }],
})Not on the collection backing the admin panel
A role-split entry, the one naming admin.user, must keep scopes: ['frontend']. Listing 'admin' there throws at config time.
The isolated strategy runs ahead of core's local-jwt, so a cookie allowed to answer admin-scoped requests would be consulted first on the very collection the panel authenticates against: a website visitor's session would win over the admin's, and the panel would lock its own admins out. The staff half needs no scope of its own, because it is on the shared cookie, which is never gated.
Without a proxy
The plugin works without one, using adminSessionPriority (on by default): with no scope header, an isolated cookie stands down as long as a valid admin token is also present. The admin panel therefore stays reachable.
The cost is that an admin-and-customer browser resolves as the admin on the website too. Set adminSessionPriority: false to invert that, or install the proxy so the scope is read rather than inferred.
An unattributed request lands in this same branch, so a session that works without the proxy keeps working once you install one.
On a role-split collection the check compares the shared cookie against admin.user, which is that collection's own slug. Same slug, different cookie: the website half stands down for a live admin session exactly as a separate collection would.
Referer stripping breaks attribution
A global Referrer-Policy: no-referrer, whether from your own security headers or a CDN, removes the signal API calls are attributed by. Every REST call then goes unattributed, and frontend sessions stop working for anyone who also holds an admin session.
Payload's admin panel and this plugin both need the same-origin Referer, so the fix is to not strip it same-origin:
headers: async () => [
{
source: '/:path*',
headers: [{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }],
},
]strict-origin-when-cross-origin is the browser default: full path same-origin, origin only cross-origin, nothing over a downgrade. If your policy is fixed and cannot change, use resolveScope to attribute requests by a signal you do control.
Next
One collection, two sessions if your auth collections are really one collection with roles, Frontends and clients for setups where the website is not this Next app, Custom auth for your own SSO, Limits for what scopes cannot cover.