Use Own Auth with a Next.js frontend and Express backend
In a split deployment, Express owns the Own Auth instance, database connection, provider callbacks, and session cookie. Next.js renders the interface and calls the Express origin. The browser never receives database credentials or reconstructs authentication decisions from client state.
Mount Own Auth on Express
Create one server instance and mount the Own Auth HTTP handler before express.json(). Allow the exact Next.js origin in both the handler origin check and the CORS response. A wildcard CORS origin cannot be used with credentialed browser requests.
import { Readable } from "node:stream";
import cors from "cors";
import express, {
type Request as ExpressRequest,
type Response as ExpressResponse,
} from "express";
import { createOwnAuth } from "own-auth";
import { createOwnAuthHandler } from "own-auth/http";
const frontendOrigin = "https://app.example.com";
const auth = createOwnAuth({
baseUrl: "https://api.example.com",
tokenPepper: process.env.OWN_AUTH_TOKEN_PEPPER!,
});
const authHandler = createOwnAuthHandler(auth, {
trustedOrigins: [frontendOrigin],
cookie: { sameSite: "none", secure: true },
mfaCookie: { sameSite: "none", secure: true },
});
const app = express();
app.use(cors({ origin: frontendOrigin, credentials: true }));
app.all("/api/auth/{*path}", async (request, response) => {
const webRequest = toWebRequest(request);
const webResponse = await authHandler(webRequest);
await sendWebResponse(response, webResponse);
});
app.use(express.json());
app.listen(3000);
function toWebRequest(request: ExpressRequest): Request {
if (!request.host) throw new Error("Host header is required");
const method = request.method.toUpperCase();
const headers = new Headers();
for (const [name, value] of Object.entries(request.headers)) {
if (Array.isArray(value)) {
value.forEach((entry) => headers.append(name, entry));
} else if (value !== undefined) {
headers.set(name, value);
}
}
const init: RequestInit & { duplex?: "half" } = { method, headers };
if (method !== "GET" && method !== "HEAD") {
init.body = Readable.toWeb(request) as ReadableStream<Uint8Array>;
init.duplex = "half";
}
return new Request(
new URL(
request.originalUrl,
request.protocol + "://" + request.host,
),
init,
);
}
async function sendWebResponse(
target: ExpressResponse,
source: Response,
) {
target.status(source.status);
for (const [name, value] of source.headers) {
if (name !== "set-cookie") target.setHeader(name, value);
}
const cookies = source.headers.getSetCookie();
if (cookies.length > 0) target.setHeader("set-cookie", cookies);
target.send(Buffer.from(await source.arrayBuffer()));
}The adapter streams the untouched request body and copies every value returned by response.headers.getSetCookie(). Register each OAuth callback URL on the Express origin, not the Next.js origin.
Choose the cookie policy from the deployment
https://app.example.com and https://api.example.com are different origins but usually the same site. The default SameSite=Lax policy can work for that arrangement. A frontend and API on different sites require SameSite=None and Secure, as shown above. Third party cookie blocking can still make that layout unreliable, so a same site API or a Next.js backend for frontend is the stronger browser deployment.
Do not set Domain=.example.com unless more than one host genuinely needs to receive the session cookie. A host only cookie limits where the browser sends the credential. The frontend does not need to read it because the cookie remains HttpOnly.
Create the Next.js client
The Own Auth TypeScript client already sends credentialed requests. Point it at the Express handler. It receives a public session shape and updates its local snapshot after sign in, MFA completion, password reset, or sign out.
import { createOwnAuthReactClient } from "own-auth/react";
export const authClient = createOwnAuthReactClient({
baseURL: "https://api.example.com/api/auth",
});"use client";
import { authClient } from "@/lib/auth-client";
export async function signIn(email: string, password: string) {
const result = await authClient.signInEmailPassword({ email, password });
if (result.status === "mfa_required") {
return { screen: "mfa" as const, methods: result.methods };
}
return {
screen: "account" as const,
user: {
id: result.user.id,
email: result.user.email,
name: result.user.name,
imageUrl: result.user.imageUrl,
},
};
}Treat mfa_required as a successful first factor. No session exists until the user completes TOTP, a recovery code, or a passkey. Do not convert that result into an HTTP 403 or read user and session fields before narrowing the status.
Forward the cookie during server rendering
A Next.js server component fetch does not automatically use the browser cookie jar. Forward the incoming cookie header to the Express session endpoint and disable caching for user specific data. Never forward that cookie to any other origin.
import { headers } from "next/headers";
export async function getServerSession() {
const incoming = await headers();
const response = await fetch("https://api.example.com/api/auth/session", {
headers: { cookie: incoming.get("cookie") ?? "" },
cache: "no-store",
});
if (response.status === 401) return null;
if (!response.ok) throw new Error("Session lookup failed");
return response.json();
}Keep authorization in Express
Next.js may hide controls for an unauthenticated user, but Express must verify the session again before reading or changing application data. Resolve the actor from the current session, reload organisation membership, and constrain every database query to the authorized tenant. A user ID or organisation ID sent by the browser is input, not proof.
Keep callbacks on one public API origin
Register Google, GitHub, and Apple callback URLs on api.example.com. The browser begins OAuth through Express, the provider returns to Express, and Express sets the session or MFA cookie before redirecting to the validated frontend destination. Do not bounce the provider callback through an extra Next.js route that can drop form data, state, or repeated cookie headers.
The destination allowlist should contain the exact frontend origins and paths the product uses. A callback destination is not permission to redirect to an arbitrary URL supplied by the browser. Test popup and redirect modes separately if the interface supports both.
Use a backend for frontend when sites differ
When the frontend and API cannot share a site, route browser authentication through the Next.js server instead of depending on cross site cookies. Next.js can proxy the bounded Own Auth HTTP contract and keep a host only cookie on the frontend site, while server requests call Express over a trusted internal connection. Preserve every response cookie and the original request origin through that proxy.
A proxy does not move authorization into Next.js. Express still verifies the resulting session or a trusted internal credential before application data access. Document which service owns the session cookie and which origin receives provider callbacks so two layers do not both try to create sessions.
Handle failures as authentication state
A 401 from the Express session endpoint means the current credential is absent or unusable. Clear client session state and render the signed out interface. A network failure or 500 response is different. Preserve the current page, show a retryable error, and do not invent a signed out state that could discard unsaved work.
Test the deployed origins through the real proxy. Verify preflight responses, trusted origin rejection, OAuth redirects, every Set-Cookie header, MFA completion, server rendered session reads, sign out, and immediate rejection after revocation. That journey proves the cookie, CORS, proxy, and backend authorization decisions as one system.