You can build a signed-cookie members area on Webflow Cloud that checks every request in Next.js middleware, protects mounted pages, and keeps public Designer content available to everyone.
A client portal or a course library is a request for pages on the Webflow site that open only for people you already know. At the same time, the marketing team keeps publishing the public site in the Designer without touching any of it.
The piece that used to mean a separate app host now runs on Webflow Cloud, mounted on the same path as the site and covered by the same site plan.
The build here is a Next.js members-area app mounted at /members. Its login page sends credentials to a Route Handler, which issues an HMAC-signed session cookie. Edge middleware sends anyone without a valid cookie back to the login page.
Everything cryptographic runs on Web Crypto because Webflow Cloud executes the app on Cloudflare Workers, and I've hand-rolled the constant-time comparison for a reason that shows up the first time you run the same code locally and deployed.
The credential store is a short JSON list of accounts held in a secret environment variable, which suits a portal with a small set of logins that you manage yourself. An identity provider should handle self-serve signup and password reset. It should also handle social login, and the session layer should be built so you can slot in a provider behind the same middleware later.
What do you need to add user authentication in Webflow?
You need five things to build this members area. Webflow Cloud is available from the free Starter site plan, while mounting the app on a custom domain requires Premium or higher.
Prepare the project, visitor credentials, signing material, mount path, and environment access before you begin:
- Webflow site: Use a site with Webflow Cloud support and choose a mount path; every URL here assumes
/members. - Next.js project: Use Next.js 15.2.3 or later within 15, managed with npm. Stay below 16, where middleware becomes
proxy.tsand cannot run on Webflow Cloud; and stay at or above 15.2.3, because earlier 15.x releases carry GHSA-f82v-jwr5-mffw, a critical bypass of exactly the middleware gate this build relies on. The scaffold command below resolves to the newest 15.x. - Visitor accounts: Gather the permitted email addresses and passwords, then hash them on your own machine before storing anything.
- Signing secret: Generate a long random string for signing session cookies, and never commit that value to the repository.
- Environment access: Make sure you can manage the site's Webflow Cloud environment settings and decide who retains access after client handoff.
With these prerequisites ready, the build hinges on configuring the mount path and secrets consistently across local development and Webflow Cloud.
6 steps to add user authentication in Webflow Cloud
The build uses six steps to create the app, configure secrets, sign sessions, handle login and logout, protect routes, and deploy a mount-path-aware login page on Webflow Cloud.
Start with the Next.js project, then carry the same cookie and base-path settings through every server and client layer.
1. Create the Next.js app for the members area
Create a fresh Next.js 15 or higher project with npm, the version and package manager required by Webflow Cloud's bring-your-own-app docs for Next.js (Astro 6 or 7 and Vite 6.1 or higher are the other supported frameworks). The package manager matters as much as the version.
The same docs state, "Currently, Webflow Cloud supports only the npm package manager." Use npm for the project and generate a package-lock.json.
Scaffold the app from the directory that will hold the repo:
npx create-next-app@15 members --typescript --app --no-src-dir --import-alias "@/*"
cd members
npm run dev
You now have a running app at localhost:3000 showing the stock Next.js page. Webflow Cloud injects the base path at build time.
Keep next.config.ts unchanged, with no basePath or output setting, and choose /members as the mount path when you set up the app's Webflow Cloud environment. The default page should load in your browser, and the repo should contain a package-lock.json.
2. Set the environment variables and hash the accounts
Set the three required environment variables, marking the two that contain credentials or signing material as secret in the Webflow Cloud environment your app deploys to. Webflow Cloud makes both secret and non-secret variables available to the build process and to the deployed app at runtime, with secrets redacted from build logs.
That build-time availability lets Next.js inline NEXT_PUBLIC_BASE_PATH into the client bundle, which the login form later depends on.
The three variables and where each one belongs:
| Variable | Secret | Value |
|---|---|---|
AUTH_SECRET |
Yes | A random string that signs and verifies session cookies |
AUTH_USERS |
Yes | A JSON array of { "email", "salt", "hash" } objects, one per visitor |
NEXT_PUBLIC_BASE_PATH |
No | /members, the mount path so that client code can build correct URLs |
| Variable → Secret → Value |
|---|
AUTH_SECRET |
| Yes |
| A random string that signs and verifies session cookies |
AUTH_USERS |
| Yes |
A JSON array of { "email", "salt", "hash" } objects, one per visitor |
NEXT_PUBLIC_BASE_PATH |
| No |
/members, the mount path so that client code can build correct URLs |
This configuration keeps the credentials and signing material secret while exposing only the mount path to client code.
Generating signing secret
Generate the signing secret with a single command:
openssl rand -base64 32
The command returns the random value you will store as AUTH_SECRET.
Save this as scripts/hash-password.mjs and run it once per account:
// Usage: node scripts/hash-password.mjs ana@example.com "her password"
const [email, password] = process.argv.slice(2);
if (!email || !password) {
console.error('Usage: node scripts/hash-password.mjs <email> <password>');
process.exit(1);
}
const encoder = new TextEncoder();
const toBase64Url = (bytes) => Buffer.from(bytes).toString('base64url');
const salt = crypto.getRandomValues(new Uint8Array(16));
const keyMaterial = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 100000 },
keyMaterial,
256,
);
console.log(JSON.stringify({ email, salt: toBase64Url(salt), hash: toBase64Url(new Uint8Array(bits)) }));
Each run prints one JSON object. Collect them into an array and paste the array as the value of AUTH_USERS. Keeping accounts here comes with two limits. Every change to the list, adding or removing a person, needs a redeploy before it takes effect, and a removed person's existing cookie keeps working until it expires.
And a single variable's size caps the list: Cloudflare limits each one to 5 KB, which at roughly 130 bytes per record is about 40 accounts.
Hashing user passwords
Webflow does not publish its own per-value limit, so treat 40 as the working ceiling. Run the script with a leading space or clear the line from your shell history afterward, since the password is typed on the command line. The script runs on your machine, where it can use Node's Buffer freely.
For local development, mirror the variables in .env.local:
AUTH_SECRET=paste-the-openssl-output-here
AUTH_USERS='[{"email":"ana@example.com","salt":"...","hash":"..."}]'
NEXT_PUBLIC_BASE_PATH=
Leave NEXT_PUBLIC_BASE_PATH empty locally, because next dev serves the app at the root. Add .env.local to .gitignore if the scaffold has not already done so.
When you finish, the Webflow Cloud environment lists all three variables with AUTH_SECRET and AUTH_USERS marked as secret, and those secrets are redacted from build logs. AUTH_USERS should also parse as valid JSON when you paste it into a console.
3. Write the session helper
Write a shared session helper that joins a base64url payload of { email, exp } to an HMAC-SHA-256 signature over that payload. A signed, stateless cookie lets the middleware verify a visitor without a database call on every request, keeping the gate cheap on the Workers runtime.
Keep the signing, verification, hashing, and comparison logic in one file that both the Route Handlers and the middleware import.
crypto.subtle is the standard Web Crypto API, present on the Workers runtime and under next dev alike, so the same file behaves identically in both places.
The non-standard helper is where the two diverge. Workers adds crypto.subtle.timingSafeEqual, a Cloudflare extension that next dev does not have. Node's own timingSafeEqual lives in node:crypto, and the global crypto object has no such method in either place. So I use a plain XOR loop that behaves the same everywhere.
Creating lib/session.ts
Create lib/session.ts with everything the login route and the middleware share:
export const SESSION_COOKIE = 'members_session';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export function toBase64Url(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function fromBase64Url(value: string) {
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
}
export function constantTimeEqual(expected: Uint8Array, actual: Uint8Array): boolean {
let diff = expected.length ^ actual.length;
for (let i = 0; i < expected.length; i++) {
diff |= expected[i] ^ (actual[i] ?? 0);
}
return diff === 0;
}
export async function hashPassword(password: string, salt: string): Promise<string> {
const keyMaterial = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt: fromBase64Url(salt), iterations: 100000 },
keyMaterial,
256,
);
return toBase64Url(new Uint8Array(bits));
}
async function signingKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
}
export async function createSessionToken(email: string, secret: string, ttlSeconds: number): Promise<string> {
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
const payload = toBase64Url(encoder.encode(JSON.stringify({ email, exp })));
const signature = await crypto.subtle.sign('HMAC', await signingKey(secret), encoder.encode(payload));
return `${payload}.${toBase64Url(new Uint8Array(signature))}`;
}
export async function verifySessionToken(
token: string | undefined,
secret: string,
): Promise<{ email: string } | null> {
if (!token || !secret) return null;
const [payload, signature] = token.split('.');
if (!payload || !signature) return null;
try {
const expected = await crypto.subtle.sign('HMAC', await signingKey(secret), encoder.encode(payload));
if (!constantTimeEqual(new Uint8Array(expected), fromBase64Url(signature))) return null;
const data = JSON.parse(decoder.decode(fromBase64Url(payload))) as { email: string; exp: number };
return data.exp > Math.floor(Date.now() / 1000) ? { email: data.email } : null;
} catch {
return null;
}
}
The file compiles with no imports because every API it touches is a web global. verifySessionToken returns null for a missing token, a bad signature, an expired payload, or malformed base64, so callers only ever branch on one condition.
The comparison loops over the trusted expected value even when the untrusted value has a different length. It folds that length difference into the result and completes the loop over the expected value. You should be able to run npx tsc --noEmit and see no errors from this file.
4. Add the login and logout Route Handlers
Add login and logout Route Handlers that re-derive the PBKDF2 hash from each submitted password and the stored salt before comparing it in constant time. The POST handler first finds the account by email and sets the cookie on success.
When the email is unknown, the handler still runs PBKDF2 against a fixed dummy salt, so login attempts take comparable time for known and unknown addresses.
Let the OpenNext Cloudflare adapter compile both handlers for the Workers runtime. Adding export const runtime = 'edge' to either handler selects a Next.js runtime target that the adapter does not support and breaks the build.
The iteration count deserves its own sentence, because 100,000 is not a free choice. It is the most the production Workers runtime accepts: above it, deriveBits throws "PBKDF2 failed: iteration counts above 100000 are not supported," and every login returns a 500.
Routing Handler runtime and PBKDF2 iterations
OWASP recommends 600,000 for PBKDF2-HMAC-SHA256, so this is a known gap and the reason a hosted identity provider is the better home for passwords at scale. Do not raise the number to close it. Neither next dev nor local wrangler dev enforces the cap, so a higher count passes every local test, fails only once deployed and changing it later invalidates every stored hash.
Each derivation counts against the Webflow Cloud limits, which are flat rather than plan-tiered: 30 seconds of Worker CPU per request.
Put the login handler in app/api/login/route.ts:
import { NextResponse } from 'next/server';
import { SESSION_COOKIE, constantTimeEqual, createSessionToken, hashPassword } from '@/lib/session';
type StoredUser = { email: string; salt: string; hash: string };
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
const DUMMY_SALT = 'AAAAAAAAAAAAAAAAAAAAAA'; // 16 zero bytes, base64url, for unknown emails
export async function POST(request: Request) {
// A cross-site HTML form cannot send a JSON body, so requiring one blocks login CSRF.
if (!request.headers.get('content-type')?.startsWith('application/json')) {
return NextResponse.json({ error: 'Expected a JSON body.' }, { status: 415 });
}
const body = (await request.json().catch(() => ({}))) as { email?: unknown; password?: unknown };
if (typeof body.email !== 'string' || typeof body.password !== 'string' || !body.email || !body.password) {
return NextResponse.json({ error: 'Email and password are required.' }, { status: 400 });
}
const secret = process.env.AUTH_SECRET;
if (!secret) {
return NextResponse.json({ error: 'Server is not configured.' }, { status: 500 });
}
const email = body.email;
const users = JSON.parse(process.env.AUTH_USERS ?? '[]') as StoredUser[];
const user = users.find((entry) => entry.email.toLowerCase() === email.toLowerCase());
const candidate = await hashPassword(body.password, user?.salt ?? DUMMY_SALT);
const encoder = new TextEncoder();
if (!user || !constantTimeEqual(encoder.encode(candidate), encoder.encode(user.hash))) {
return NextResponse.json({ error: 'Invalid email or password.' }, { status: 401 });
}
const token = await createSessionToken(user.email, secret, SESSION_TTL_SECONDS);
const response = NextResponse.json({ ok: true });
response.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: process.env.NEXT_PUBLIC_BASE_PATH || '/',
maxAge: SESSION_TTL_SECONDS,
});
return response;
}
The cookie's path matches the mount path, so the browser sends it only for requests under /members. Designer-built pages at the site root do not receive it. Locally, the variable is empty, and the path falls back to /.
The logout handler in app/api/logout/route.ts clears the same cookie:
import { NextResponse } from 'next/server';
import { SESSION_COOKIE } from '@/lib/session';
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set(SESSION_COOKIE, '', {
path: process.env.NEXT_PUBLIC_BASE_PATH || '/',
maxAge: 0,
});
return response;
}
The login endpoint is intentionally unauthenticated because it creates the session. Add rate limiting or account lockout before production to constrain password guessing and the CPU cost of repeated PBKDF2 work. The logout endpoint acts only on the caller's session cookie.
The JSON check at the top of the login handler is its CSRF defense. SameSite: 'lax' does nothing for this endpoint, because a login request carries no session cookie yet; without the check, a form on another site could post a plain-text body and log a visitor into an account the attacker controls.
The logout handler has no such check, so a cross-site request can sign a member out, which is a nuisance rather than a breach.
Security considerations
Client-visible environment-variable leakage is limited to NEXT_PUBLIC_BASE_PATH, which is intentionally public and contains only /members. AUTH_SECRET and AUTH_USERS are not public-prefixed and are read only by server-side code.
With next dev running, a curl -i -X POST localhost:3000/api/login -H 'Content-Type: application/json' -d '{"email":"ana@example.com","password":"her password"}' returns 200 with a Set-Cookie: members_session=... header; a wrong password returns 401 in roughly the same time.
5. Protect the app with middleware.ts
Create the middleware file at the project root beside the app directory, where it can gate every route except the login page and the API. Webflow's framework customization docs state, "Only Edge runtime middleware works on the Workers runtime."
Next.js runs middleware.ts on its Edge runtime by default, which is exactly what the Workers runtime accepts, and it does so without any runtime export.
Next.js 16 renames middleware to proxy, and proxy.ts runs on the Node runtime with no way to opt into Edge, which is why this build stays on Next.js 15 and keeps the file as middleware.ts.
Build the redirect from request.nextUrl.clone(). NextURL carries the injected base path with it, so the visitor lands on /members/login. A redirect built from new URL('/login', request.url) would point to a nonexistent /login at the site root.
Create middleware.ts at the project root:
import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE, verifySessionToken } from '@/lib/session';
export async function middleware(request: NextRequest) {
const token = request.cookies.get(SESSION_COOKIE)?.value;
const session = await verifySessionToken(token, process.env.AUTH_SECRET ?? '');
if (session) {
return NextResponse.next();
}
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = '/login';
return NextResponse.redirect(loginUrl);
}
export const config = {
matcher: ['/((?!login$|api/login$|api/logout$|_next/|favicon\\.ico$).*)'],
};
The matcher is a negative lookahead that skips exactly five things: the /login page, the login and logout routes, Next.js assets under /_next/, and the favicon. The anchors matter. Without the $ and trailing slashes, the lookahead skips any path that merely starts with those letters, so a members page at /api-guide or /login-history would render for anyone.
Naming the two open routes, rather than skipping all of /api/, also means any API route you add later is protected by default, not public by default. Loading localhost:3000 in a private window now redirects to localhost:3000/login, which returns 404 until the login page exists; that 404 confirms that the middleware fired.
6. Build the login page and deploy
Build a protected home that reads the session server-side and a client-rendered login form that prefixes every fetch with the mount path.
That prefix is the part people miss. Next.js mounts server-side route handlers at the injected base path automatically, and the bring-your-own-app docs are explicit that "Client-side fetch calls must manually include the base path to correctly reach your endpoints."
A form that posts to /api/login works under next dev. Once deployed, that request routes outside the mounted app instead of reaching /members/api/login.
Replace app/page.tsx with a protected home that reads the session:
import { cookies } from 'next/headers';
import { SESSION_COOKIE, verifySessionToken } from '@/lib/session';
export default async function MembersHome() {
const cookieStore = await cookies();
const session = await verifySessionToken(
cookieStore.get(SESSION_COOKIE)?.value,
process.env.AUTH_SECRET ?? '',
);
return (
<main>
<h1>Members area</h1>
<p>Signed in as {session?.email ?? 'unknown'}</p>
</main>
);
}
The middleware has already rejected anonymous visitors by the time this renders, so the 'unknown' fallback exists only to keep TypeScript honest.
Creating login page component
The login form goes in app/login/page.tsx as a client component:
'use client';
import { useState, type FormEvent } from 'react';
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
const data = new FormData(event.currentTarget);
const response = await fetch(`${basePath}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: data.get('email'), password: data.get('password') }),
});
if (!response.ok) {
setError('That email and password did not match.');
return;
}
window.location.assign(basePath || '/');
}
return (
<main>
<h1>Sign in</h1>
<form onSubmit={handleSubmit}>
<label>
Email
<input name="email" type="email" autoComplete="email" required />
</label>
<label>
Password
<input name="password" type="password" autoComplete="current-password" required />
</label>
<button type="submit">Sign in</button>
</form>
{error && <p role="alert">{error}</p>}
</main>
);
}
Deploy the app to the Webflow Cloud environment mounted at /members, with the three environment variables set before the build runs so that NEXT_PUBLIC_BASE_PATH is inlined.
Open yourdomain.com/members in a private window. You are redirected to /members/login, a correct password sends you back to /members showing "Signed in as" with your email, and the response headers on the login request show Set-Cookie with HttpOnly, Secure, and Path=/members.
What causes user authentication to fail in Webflow Cloud?
Authentication usually fails because of an incorrect mounted base path, an unsupported Route Handler runtime, a renamed middleware file, or a cryptographic helper that behaves differently during local development.
Start with the failing request in the browser Network tab or the relevant build log, then match the symptom to these Webflow Cloud checks.
Sign-in returns 404 on the live site but works under next dev
Cause: A POST to /api/login in the Network tab confirms that the deployed bundle was built without /members. Depending on the site-root route, that request can surface as a 404, and page reloads cannot change the inlined value. Webflow Cloud serves the app under its mount path, but client-side requests don't automatically get that prefix.
Fix: Set NEXT_PUBLIC_BASE_PATH to /members before the build and trigger a new build; editing the variable alone changes nothing in the shipped bundle. Confirm in the DevTools Network tab that the POST goes to /members/api/login and that the Set-Cookie header reads Path=/members.
I've seen the cookie half of this fail on its own: the fetch is right, but the cookie path is /, which still works, and only shows up as a leak when the cookie starts riding along on every public page request.
The build fails after adding export const runtime = 'edge' to a Route Handler
Cause: The Route Handler contains export const runtime = 'edge', which selects a Next.js runtime target that the OpenNext Cloudflare adapter does not support. The bring-your-own-app page tells you to add it to API routes, so a developer who follows that instruction can reasonably conclude the platform is at fault.
Middleware is different because Next.js already runs middleware.ts on its Edge runtime by default.
Fix: Delete the line from every Route Handler and page. If the project came from a host that required the directive, search the repo for runtime = 'edge' and remove each occurrence.
Leave the root-level middleware.ts file without a runtime export. Rebuild after the search so the adapter compiles the login and logout handlers for the Workers runtime while middleware keeps the runtime behavior Webflow Cloud accepts.
The gate stops working after renaming middleware.ts to proxy.ts
Cause: A Next.js upgrade codemod may have changed the root-level filename while leaving the code inside unchanged. The local dev server may still behave as before, but proxy.ts runs on the Node runtime with no way to opt into Edge.
Webflow Cloud's Workers runtime requires the Edge behavior supplied by middleware.ts, so the deployed gate stops running and fails open. Every members page is served without a login, with no error anywhere.
Fix: Restore the filename to middleware.ts and confirm the export is still named middleware with the config.matcher intact. Verify on the deployed site: open yourdomain.com/members in a private window and check for the redirect to /members/login.
Agency developers handing a site off should leave a comment at the top of the file saying why it must retain its name, because the next Next.js upgrade will suggest the rename again.
crypto.subtle.timingSafeEqual is not a function under next dev, while the deployed app is fine
Cause: crypto.subtle.timingSafeEqual is a Cloudflare extension to Web Crypto that exists on the deployed Workers runtime, while Node's Web Crypto under next dev lacks it. Swapping in that built-in helper can therefore produce a green deploy and a red terminal.
A runtime fallback can also leave production running comparison code that local tests never exercised, which makes the behavior harder to verify consistently across the two environments.
Fix: Restore the constantTimeEqual helper in lib/session.ts so local and deployed comparisons follow the same code path. Avoid branching on typeof crypto.subtle.timingSafeEqual.
The same runtime-specific check applies to Node modules: node:path is available on Webflow Cloud, while node:fs is not. Check each module against the documentation for Workers and local Node instead of inferring support from a successful compile.
What you can build next with user authentication and Webflow Cloud
Once a verified email travels with every request under /members, gated content becomes an ordinary rendering problem. A server component can read the session, look up what that visitor is entitled to, and pull matching collection items from the Webflow CMS. It can use the REST API, so the marketing team keeps editing members-only articles in the same place as the public ones.
Give those collections no public template page, though: a published collection page on the Designer site sits outside /members, and the gate never sees it. Per-account pricing and a profile page share the same cookie. Download links can expire with the session.
When the account list outgrows an environment variable, the login Route Handler becomes the callback for an identity provider that returns a verified email, such as Auth0 or Supabase Auth, and createSessionToken turns that email into the same cookie the middleware already trusts.
Frequently asked questions
How can I revoke all active sessions?
Replace AUTH_SECRET in the environment, then redeploy. Revocation happens when the new deployment goes live, not when you save the variable. Every existing cookie carries a signature made with the old value, so verification returns null and middleware redirects each visitor to login. Use this when broad revocation matters, then keep the replacement secret outside the repository.
Can I revoke access for one account immediately?
Not with the current stateless check. Middleware verifies only the cookie signature and expiration, so removing an entry from AUTH_USERS does not invalidate a cookie that was already issued. Immediate per-account revocation requires an additional server-side account or revocation check; otherwise, rotate AUTH_SECRET to invalidate every active session at once.
Are email addresses case-sensitive at sign-in?
No. The login handler lowercases both the submitted address and each stored address during lookup. After a match, it puts the email spelling from AUTH_USERS into the signed session token. As a result, a visitor can vary capitalization at sign-in while the members page consistently displays the canonical stored address.
How do I add a logout button to the members area?
The supplied login page does not include a logout control, but the logout Route Handler is ready to use. Add a client-side button that sends a POST request to ${basePath}/api/logout, then redirect the visitor to ${basePath}/login. The handler clears the cookie using the same mount-aware path as login after signing out.
What happens if AUTH_USERS contains duplicate emails?
The handler uses find with a case-insensitive comparison, so the first matching object in AUTH_USERS wins. Two entries whose addresses differ only by capitalization are therefore ambiguous, and later entries will never be selected. Keep each email unique after lowercasing, and regenerate the JSON array if you discover a duplicate.





