@iaurelius/accounts-nextjs
v0.4.0
Published
Accounts by iAurelius for Next.js (App Router): sign-in on the hosted page, sessions in an encrypted cookie, auth() on the server and <UserButton /> in your UI.
Maintainers
Readme
@iaurelius/accounts-nextjs
Accounts by iAurelius for Next.js (App Router, Next 15.5.24+ and 16.3.3+).
People sign in on your application's hosted page, on your domain with passkeys, email codes and Google, and come back signed in. In your app you get:
auth()andcurrentUser()on the server;useAuth()anduseUser()in the browser;- ready-made
<SignInButton />,<UserButton />,<SignedIn>and<SignedOut>.
The tokens stay on the server, in an encrypted HttpOnly cookie. The SDK refreshes them before they expire.
Quick start
npx @iaurelius/accounts initIt installs the package, registers your development app, and writes .env.local and proxy.ts. To do it by hand, follow the three steps below.
1. Settings (.env.local)
Copy them from your application in the console, under Apps and APIs:
NEXT_PUBLIC_ACCOUNTS_PUBLISHABLE_KEY=pk_live_…
ACCOUNTS_CLIENT_ID=…
# Production (a web app, https): its secret. The development app (localhost) has none.
ACCOUNTS_CLIENT_SECRET=…
# Always: your own random secret for the session cookie (openssl rand -base64 32), one per environment.
ACCOUNTS_COOKIE_SECRET=…
# Optional: your API's identifier, for getToken().
# ACCOUNTS_AUDIENCE=https://api.example.comACCOUNTS_COOKIE_SECRET is required, in development and in production. It is the key of the session cookie, so whoever has it can write a session for anyone: keep it on your app's servers only, and never reuse the client secret for it (the SDK refuses to start if you do). Changing it signs everyone out.
The development app is a public app, with no secret and PKCE only. It allows http://localhost redirects, which a web app cannot have. Register it with this callback, and use your real port:
http://localhost:3000/api/accounts/callbackas the redirect URI;http://localhost:3000/as the sign-out URI.
In production, a web app (confidential, https) uses the same two URLs on your domain.
2. proxy.ts
Use middleware.ts before Next 16.
import { accountsMiddleware } from "@iaurelius/accounts-nextjs/server";
export default accountsMiddleware({ protect: ["/dashboard(.*)"] });
export const config = {
matcher: ["/((?!_next/static/|_next/image$|favicon\\.ico$).*)"],
};The proxy does four things:
- It answers
/api/accounts/{sign-in,sign-up,callback,sign-out,session,token}, so you write no route files. - It keeps the session fresh.
- It sends people who are signed out of a protected page to the sign-in, and back to that page afterwards. Protected API routes answer
401instead. - It hands the session to the rest of the request.
Keep the matcher as it is: it runs the proxy on every path except Next's build output (/_next/static/… and /_next/image) and /favicon.ico itself, each matched exactly. A matcher that skips paths by their extension (.png, .csv…) also skips your own pages asked that way, such as app/files/[name]/page.tsx for /files/a.png; one that skips a prefix without anchoring it (favicon.ico instead of favicon\.ico$) skips /favicon.ico-acme/dashboard for app/[team]/dashboard.
protect is a redirect for people, not the lock on your data. Check who is asking where the data is read: await auth.protect() in the page or layout, and auth() in route handlers and server actions. That check reads the session itself, so it holds even for a request the proxy did not see.
3. app/layout.tsx
import { AccountsProvider, SignInButton, SignedIn, SignedOut, UserButton } from "@iaurelius/accounts-nextjs";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AccountsProvider>
<header>
<SignedOut><SignInButton /></SignedOut>
<SignedIn><UserButton /></SignedIn>
</header>
{children}
</AccountsProvider>
</body>
</html>
);
}<UserButton /> is the avatar menu. It shows who is signed in and has "Manage account" and "Sign out":
- "Manage account" opens the hosted account page, which has a way back to where the person was. For that way back, allow your app's origin in the console under Emails and paths → Allowed origins.
- "Sign out" also ends the session on the hosted pages.
The buttons speak English or Spanish: <AccountsProvider locale="es">.
On the server
import { auth, currentUser } from "@iaurelius/accounts-nextjs/server";
export default async function Page() {
const { userId, user } = await auth.protect(); // signed out: to the sign-in and back
const me = await currentUser(); // the latest profile, from the application
return <p>Hello {user.name ?? user.email}</p>;
}auth() returns:
isAuthenticated,userId,sessionId,orgIdanduser;getToken(), the access token for your API;protect()andredirectToSignIn().
It works in server components, route handlers and server actions.
In the browser
"use client";
import { useAuth, useUser } from "@iaurelius/accounts-nextjs";
const { isSignedIn, getToken, signOut } = useAuth();
const { user } = useUser();
const res = await fetch("https://api.example.com/me", { headers: { authorization: `Bearer ${await getToken()}` } });Your API checks that token with @iaurelius/accounts, for example with accountsMiddleware() for Hono.
Security
- Sign-in:
- Authorization code with PKCE (S256),
stateandnonce, and RFC 9207'sisschecked. - The ID token is verified: signature, audience and nonce.
- Authorization code with PKCE (S256),
- Session cookie: encrypted (A256GCM) with a key derived from
ACCOUNTS_COOKIE_SECRET(never from the client secret),HttpOnlyandSameSite=Lax. On https it is__Host-accounts_session,Secureand host-only, so another subdomain of your domain cannot plant or shadow it; the sign-in attempt's cookie is__Host-too. The plain__accounts_sessionof earlier versions is not read on https and is removed when it comes along.auth()on a request the proxy did not see reads the plain name only when the app is on http for sure (ACCOUNTS_APP_URL, or else the request'sx-forwarded-proto, says http). Responses that set it areCache-Control: private, no-store. - Redirects: after sign-in the person only ever goes back to a path of your app.
- Sign-out: only from your app's own pages (Origin checked). The refresh token is revoked and the hosted session ends.
- Revocation: a person who is banned or signed out everywhere is out at the next refresh.
- Refresh: only a refusal for good signs the person out: a
4xxwith an OAuth error, such asinvalid_grant(revoked, banned),invalid_client,invalid_scopeorinvalid_target(your audience changed). If the application answers429or5xx, does not answer, or a4xxcomes without an OAuth error (a firewall's page), the session and its refresh token are kept and the access token serves while it lasts. After a429(or a503withRetry-After), no refresh of the app is tried before the server'sRetry-After; after anything else, only that person's refresh waits a few seconds. A refreshed ID token must be the same person (iss,sub,aud), and userinfo'ssubmust match.
License
MIT
