Skip to main content

Node.js — Auth.js

The least code of any option. Auth.js (formerly NextAuth) handles sessions, cookies, PKCE, token refresh, and CSRF with a single config object.

Works with Next.js, SvelteKit, Nuxt, and plain Express.

Install (Next.js App Router)

npm install next-auth
npx auth secret # generates AUTH_SECRET

Setup — 2 Files

// auth.ts (project root)
import NextAuth from "next-auth"

refreshAccessToken — only needed if you asked for offline_access

Access tokens last an hour. This exchanges the refresh token for a new pair when one expires. Skip it entirely if your app only needs to know who signed in.

async function refreshAccessToken(token: Record<string, unknown>) {
const response = await fetch("https://v1.0account.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: token.refreshToken as string,
client_id: process.env.ZERO_CLIENT_ID!,
client_secret: process.env.ZERO_CLIENT_SECRET!,
}),
})
const tokens = await response.json()
if (!response.ok) throw tokens
return {
...token,
accessToken: tokens.access_token,
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in as number),
// Use the new refresh token if the server rotated it
refreshToken: (tokens.refresh_token as string) ?? token.refreshToken,
}
}
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
{
id: "0account",
name: "0account",
type: "oidc",
issuer: "https://v1.0account.com",
clientId: process.env.ZERO_CLIENT_ID,
clientSecret: process.env.ZERO_CLIENT_SECRET,
// offline_access requests a refresh token
authorization: { params: { scope: "openid profile email offline_access" } },
// Required. Auth.js's default mapping looks for `name`, which we do not
// send — userinfo returns given_name and family_name separately, so
// without this session.user.name is undefined.
profile(profile) {
return {
id: profile.sub,
name: [profile.given_name, profile.family_name].filter(Boolean).join(" "),
email: profile.email,
image: profile.picture,
}
},
},
],
events: {
async signOut(message) {
// Server-to-server: terminate the session on 0account's side without a browser redirect.
if ("token" in message && message.token?.idToken) {
await fetch("https://v1.0account.com/oauth/logout", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ id_token_hint: message.token.idToken as string }),
}).catch(() => {})
}
},
},
callbacks: {
async jwt({ token, account }) {
// Persist tokens from the initial sign-in
if (account) {
return {
...token,
accessToken: account.access_token,
idToken: account.id_token,
expiresAt: account.expires_at,
refreshToken: account.refresh_token,
}
}
// Return token if it has not expired yet
if (Date.now() < (token.expiresAt as number) * 1000 - 60_000) return token
// Refresh the access token
try {
return await refreshAccessToken(token)
} catch {
return { ...token, error: "RefreshAccessTokenError" }
}
},
async session({ session, token }) {
session.accessToken = token.accessToken as string
if (token.error) session.error = token.error as string
return session
},
},
})
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlers

Using the Session

// app/page.tsx
import { auth, signIn, signOut } from "@/auth"

export default async function Page() {
const session = await auth()

if (!session) {
return (
<form action={async () => { "use server"; await signIn("0account") }}>
<button type="submit">Sign in with 0account</button>
</form>
)
}

// Show a warning if the token could not be refreshed
if (session.error === "RefreshAccessTokenError") {
return <form action={async () => { "use server"; await signIn("0account") }}>
<button type="submit">Session expired — sign in again</button>
</form>
}

return (
<div>
<p>Welcome, {session.user?.name}</p>
<form action={async () => { "use server"; await signOut() }}>
<button type="submit">Sign out</button>
</form>
</div>
)
}

Auth.js handles the redirect, callback, state, PKCE, and session automatically. The signOut() call triggers RP-Initiated Logout via the end_session_endpoint from the discovery document.

Reading custom fields

session.user carries what the profile() mapping above returns. Everything you configured on your app — including fields with no OIDC equivalent — arrives under a namespaced claim on the raw profile, so pass it through the callbacks.

// auth.ts — add to the callbacks you already have
const FIELDS = "https://0account.com/claims/fields"

callbacks: {
async jwt({ token, profile }) {
// profile is present only on the first call, right after sign-in
if (profile) token.fields = profile[FIELDS] ?? {}
return token
},
async session({ session, token }) {
session.fields = token.fields as Record<string, unknown>
return session
},
}
const session = await auth()
const petName = session?.fields?.petName

The key is whatever you named the field, in camelCase: a field titled Pet name arrives as petName. Standard fields are in there too, so you can read everything from one place if you prefer.

Environment Variables

ZERO_CLIENT_ID=your-0account-client-id
ZERO_CLIENT_SECRET=your-0account-client-secret
AUTH_SECRET=your-random-secret # run: npx auth secret

Using with Express

import { ExpressAuth } from "@auth/express"

app.use("/auth/*", ExpressAuth({
providers: [{
id: "0account",
name: "0account",
type: "oidc",
issuer: "https://v1.0account.com",
clientId: process.env.ZERO_CLIENT_ID,
clientSecret: process.env.ZERO_CLIENT_SECRET,
authorization: { params: { scope: "openid profile email offline_access" } },
}],
}))

Going further

This page covers sign-in with 0account and nothing else. For everything the library itself does — middleware, adapters, session storage, multiple providers — see the Auth.js documentation.