Stop Doing Auth in React
Auth is two questions, and a server settles both of them during a request. Everything you were about to write in a context provider is data fetching in a costume. Five diagrams, and a map of every provider selling you a slice of it.
Ask where auth lives and you get answers about packages, providers, and a context holding a blob of user data. None of that is auth.
Auth is two questions:
- Authentication. Are you who you say you are?
- Authorization. May that person do this?
A ban button settles the argument. You click it, and the click means nothing until something confirms you are Sam, Sam owns this room, and Sam may remove people from it. The first is authentication. The other two are authorization. All three get answered somewhere you do not control.
Where it happens
On the server, during a request.
Requests are the only place both questions have an answer, because a request is the only thing that carries proof. So the follow-up question answers itself. How do you know who the user is in React? You ask. GET /api/me returns the session for whoever sent the cookie, or a 401.
// app/api/me/route.ts
import { cookies } from "next/headers";
import { getSession } from "@/lib/session";
export async function GET() {
const token = (await cookies()).get("session")?.value;
const session = token && (await getSession(token));
if (!session) return Response.json({ user: null }, { status: 401 });
return Response.json({ user: session.user });
}
Share that response through a context or React Query once it lands. The provider wrapping your app should hold two states, signed in and signed out, and stay out of the way. Every question past that point is a fetch.
The cookie is the boring part
Getting authenticated
once
- 1You open the page and the server has never met you.
- 2You sign in. The credentials cross the wire one time.
- 3The server checks them and answers with Set-Cookie.
- 4The cookie lands where no script can reach it.
Getting authorized
every request after that
- 1You ask for something. The cookie goes along for the ride.
- 2The server parses the cookie and looks up who sent it.
- 3The server checks whether that person may do this.
- 4It answers, and the UI does what the answer says.
The token needs to reach the server on its own, which is what a cookie does and what localStorage does not. Set it HttpOnly and Secure and the browser attaches it to every same-origin request while keeping it away from your scripts. That second property is the one that matters. A token in localStorage is readable by any script on the page, which includes the analytics tag you added last week, the extension your user installed, and whatever a compromised dependency shipped this morning. It also drifts out of sync with the server, since nothing on the client can tell you a session died.
Next Auth, Better Auth, and Clerk all put a signed cookie in the right place with the right flags. Use one of them and skip the rest of this paragraph.
JWTs move the deadline
A signed token flips the model. The server stops looking anything up and starts trusting math, which is fast and which means you cannot take the token back.
Clerk's answer is a sixty-second window: a JWT lives in the browser, and the SDK refreshes it every minute. Delete an account and the ghost gets one more minute of your permissions. Fifteen API calls fired from a page load all ride the same token without a round trip. That trade reads well to me.
Whatever you do, keep the payload small. A JWT holding the user's friend list rides along on every single request for the rest of the session, and you already have a way to ask for a friend list.
The client renders the answer
Say the ban button only appears for mods. That is a fetch, and then a render:
const { data, isLoading } = useQuery({ queryKey: ["me"], queryFn: fetchMe });
if (isLoading) return <ButtonSkeleton />;
if (data?.role !== "admin") return null;
return <BanButton />;
Reach for isLoading rather than isFetching. The first means no data yet; the second flips whenever any component with that key refetches, so a sibling calling the same endpoint will blank your button for no reason.
Hiding the button is a courtesy to the user. The server still has to reject the request, because the person you are hiding it from can open the network tab.
Three circles
Providers sell you slices of three separate things, and most arguments about them come from two people comparing different slices.
Pick a name. Tap once to pin it.
Verification is a yes or a no about identity. User info is the record: the email, the avatar, the role, the org. Auth UI is the sign-in page, the account manager, and the Google button you are not allowed to restyle, because Google will fail your OAuth review over the wrong shade of blue.
Once you see the circles, the real question stops being which library and becomes who owns the users table.
Own it yourself and joins work. SELECT * FROM users WHERE id IN (friend_ids) is one query. Better Auth and Auth.js put the table in your database, hand you adapters, and leave the gluing to you. Rent it instead and that join becomes a fan-out of SDK calls, which is fine right up until you build a friends list. Clerk hosts everything, gets you running in five minutes, and works the same from a Next.js route, an Expo app, a Chrome extension, and Swift.
The middle ring is crowded because the middle is where most products want to be. Stack Auth is Clerk's shape with an exit hatch. WorkOS AuthKit covers all three: authentication, users and organizations, and a hosted UI you can replace with your own. OpenAuth sits alone in the left circle on purpose: a standalone verifier on your own infra with a KV store next to it, and no opinion about who your users are.
Two entries deserve warnings. Lucia is deprecated, and its author's parting note is worth reading, since maintaining database adapters is what killed it. Passport still works and has not been touched in two years. Firebase Auth is cheap and gets misconfigured at a rate that produced a whole genre of exploit writeups.
The middleware argument
Then there is the pattern where auth gets hoisted into proxy.ts (Next 16's rename of middleware.ts) and every route in the app pays for it.
Two costs. The obvious one is latency: a blocking hop in front of your blog, your feed, and every static page you were about to serve from cache. The other one shows up later, when the check needs to know whether Sam is a mod of room 42. Route patterns cannot express that, so the file grows a copy of your routing logic, then a copy of your permissions model, and it still runs on /rss.xml.
Put the check where the data is. Next 16 ships unauthorized() behind experimental.authInterrupts, which throws to a 401 and renders your unauthorized.tsx:
// app/dashboard/page.tsx
import { unauthorized } from "next/navigation";
import { verifySession } from "@/lib/dal";
export default async function DashboardPage() {
const session = await verifySession();
if (!session) unauthorized();
return <Dashboard user={session.user} />;
}
It works from server components, server functions, and route handlers, so the same call covers a page load and the action fired from it.
One trap while you are down here: a check in layout.tsx does not cover the pages under it. Layouts skip re-rendering on client-side navigation, so the session goes unchecked on the route change. Call verifySession in the page too, or better, call it inside the function that reads the data, where nobody can route around it.
Sign-in methods, ranked
OAuth first. Someone else runs the password reset flow, eats the support tickets, and takes the blame for the breach.
Passkeys next. They are genuinely secure and the ceremony still takes five taps, which is the only thing holding them back.
Magic links work. I find them irritating in a way I cannot fully justify.
Passwords last, and only if the choice was made for you. Microsoft has published what its own internal password resets cost, and the number has commas in it. Passwords cost money, generate breaches, and make your users' lives worse. When the setup screen lets you leave that box unchecked, leave it unchecked.
Picking one
- You want it working today, on every platform, and you can spend $25/mo past ten thousand users. Clerk.
- You want the users table in your own database. Better Auth. The plugin list covers 2FA, orgs, passkeys, and OTP, and you own every row.
- You want a hosted product with a real exit. Stack Auth.
- You want a verifier and nothing else, on your own infra. OpenAuth. Budget a couple of days.
- Your customers have a procurement team. WorkOS AuthKit.
Pick by what you want to own. Everything else on the list is a data-fetching problem you were going to have anyway.