@baseauth/nextjs
v0.1.2
Published
Next.js SDK for baseauth — offline token verification, single-flight refresh, org-scoped sessions. Plain Request/Response handlers; imports neither react nor next.
Maintainers
Readme
@baseauth/nextjs
Next.js SDK for baseauth.
Access tokens are verified offline against the published JWKS — baseauth is never in the request path of a normal API call, only at login, refresh, and logout.
Install
npm install @baseauth/nextjsConfigure
# .env.local
BASEAUTH_ISSUER=https://auth.example.com
BASEAUTH_CLIENT_ID=app_7c31…
BASEAUTH_CLIENT_SECRET=… # confidential client — the server exchanges the code
BASEAUTH_REDIRECT_URI=https://yourapp.com/api/auth/callback
BASEAUTH_COOKIE_SECRET=… # encrypts the refresh-token cookie at rest
# Optional
BASEAUTH_ORGANIZATION=org_… # required if your issuer is multi-tenant
BASEAUTH_POST_LOGOUT_REDIRECT_URI=https://yourapp.com/None of these is NEXT_PUBLIC_*, and none should be. They are read on the server; putting
the issuer or the client secret in the browser bundle is how a wrong value gets compiled in
and shipped.
Send someone to sign up rather than sign in
/api/auth/login opens the sign-in form. Pass intent=signup for the create-account form
instead, and return_to for a same-origin path to land on afterwards:
/api/auth/login?intent=signup&return_to=%2Fwelcomereturn_to is rejected unless it is a relative path — an absolute one would make the login
route an open redirect, which is a credible phishing primitive because the domain in the
address bar is genuinely yours right up until the bounce.
Wire it up
// app/api/auth/[...baseauth]/route.ts
import { createHandlers, configFromEnv } from '@baseauth/nextjs'
const handlers = createHandlers(configFromEnv(process.env))
export const GET = handlers.GET
export const POST = handlers.POSTThat serves /login, /callback and /logout. Refresh is not a route — it happens inside
getSession when the access token is close to expiry.
Constructing at module scope like this is safe: configuration is validated on the first
request, not here. next build evaluates route modules while collecting page data, so a
constructor that threw would fail the build rather than the request — and your image could
not be built without production secrets.
Read a session
import { createServerClient, configFromEnv } from '@baseauth/nextjs/server'
// Also safe at module scope, for the same reason.
const auth = createServerClient(configFromEnv(process.env))
// null when signed out
const session = await auth.getSession(request)
// throws rather than returning null
const session = await auth.requireSession(request, { orgId: tenant.baseauthOrgId })The three rules that matter
Always pass orgId. A valid signature is not an authorization decision. An access token
minted for tenant A is perfectly well-signed when presented to tenant B's route, and
requireSession({ orgId }) is what makes that a rejection instead of a cross-tenant leak:
await auth.requireSession(request, { orgId: tenant.baseauthOrgId })
// throws OrgMismatchErrorWrite back the cookies a refresh produced. getSession may rotate the session
transparently; the new cookie only reaches the browser if you attach it:
const response = NextResponse.next()
for (const cookie of auth.pendingCookies()) {
response.headers.append('set-cookie', cookie)
}Revocation is not instant. Offline verification means baseauth never sees your request, so killing a session takes effect within one access-token lifetime — up to ten minutes. Do not build a flow that assumes a killed session stops working on the very next request.
What it handles for you
- Single-flight refresh. A page rendering five components that each need a token would otherwise fire five refreshes; because refresh tokens rotate, four of them present an already-spent token and look exactly like theft to the server, which then revokes the family. The SDK collapses them into one.
- Offline JWKS verification, with a re-fetch on an unknown
kidand the accepted algorithms pinned by configuration rather than read from the token header. - PKCE and
state, bound to the browser that started the flow. - Cookie encryption for the refresh token, which never reaches JavaScript.
Two pages your app has to serve
baseauth hosts sign-in, sign-up and "forgot your password". It does not host the two pages its emails link into, because those links have to land on the domain your users already recognise — asking someone to type a new password on an auth domain they have never seen is the shape of a phishing mail.
| Path | Reached from | Posts to |
| --- | --- | --- |
| /reset-password?token=… | the password-reset email | POST {issuer}/api/v1/account/password/reset with { token, password } |
| /verify-email?token=… | the verification email | POST {issuer}/api/v1/account/email/verify with { token } |
Both endpoints are unauthenticated and CORS-enabled for your origin, so a browser can call them directly. Proxying through your own server instead keeps the issuer's URL out of the bundle, which is what the rest of this SDK is for.
Two behaviours worth building for on the reset page. weak_password comes back with a
message naming the policy and does not consume the token, so keep the form on screen;
every other error means the link is spent or expired and the person needs a new one. And a
completed reset revokes every session on the account including the current browser, so end
on "sign in again" rather than assuming they are still signed in.
Without these pages the emails are dead ends, and a forgotten password becomes the end of the account.
Hosting the sign-in page yourself
baseauth's hosted pages are the default and stay right for most apps. If your product's visual identity will not tolerate a sign-in screen on somebody else's domain, an organization can name a page of its own:
PATCH {issuer}/api/v1/organizations/{org id}
Authorization: Bearer {m2m access token}
{ "interaction_url": "https://yourapp.com/auth" }Set BASEAUTH_CROSS_SITE_INTERACTION=1 on the auth server as well. Your form is on your
domain and posts to the issuer, which is cross-site, so the interaction cookie has to be
SameSite=None; Secure to survive the trip. Both sides must then be https; the server
refuses to boot with the flag set and an http issuer rather than failing on every request.
What moves is the HTML and nothing else. Your form posts back to the same endpoints the
hosted page posts to, so rate limiting, account lockout, the MFA step, federated
connections, the audit trail and the enumeration guarantees all still apply. You write a
form; baseauth still decides what a submission means. null puts it back on the hosted
pages.
Your page is reached by redirect with these query parameters:
| Parameter | Meaning |
| --- | --- |
| uid | The interaction id. Post it back in the URL. |
| prompt | login, signup, or mfa — which form to render. |
| error | Present only after a failed attempt. See below. |
| login_hint | An address to pre-fill, when the request carried one. |
Post to {issuer}/interaction/{uid}/login, /signup or /mfa, matching the prompt.
?intent=signup on /api/auth/login arrives as prompt=signup, so the create-account
button works the same as it does on the hosted pages.
The error codes, which are all your page needs to render a message:
| Code | Prompt | Means |
| --- | --- | --- |
| invalid_credentials | login | Wrong address or password — or too many attempts. Deliberately the same code: distinguishing them tells an attacker which addresses exist. |
| invalid_code | mfa | The second factor did not match. |
| too_many_attempts | mfa | Second-factor attempts are throttled. |
| email_invalid | signup | Not a usable address. |
| email_taken | signup | Already registered — offer sign-in. |
| weak_password | signup | Fails the password policy. |
| rate_limited | signup | Too many accounts created from this address or IP. |
Write your own copy for each: the codes are stable, the wording is yours. Anything else in
error should render as a generic failure rather than being echoed to the page.
One limitation worth knowing before you build: forgot-password stays on the hosted page.
Your "forgot your password?" link points at {issuer}/interaction/{uid}/forgot, and the
person sees baseauth's page for that step. The two pages the reset email links into are
yours to serve, and are described above.
Runtime
App Router first; Pages Router works too. Runs on both the Edge and Node runtimes — the
verifier uses Web Crypto and the package deliberately compiles without Node built-ins, so a
stray node:* import fails the build rather than the deploy.
Full guide
See INTEGRATION.md for multi-tenant provisioning, the Management API, and per-platform notes.
License
MIT
