@smittdev/next-jwt-auth
v1.2.0
Published
Zero-config JWT authentication scaffolder for Next.js App Router
Maintainers
Readme
@smittdev/next-jwt-auth
Zero-config JWT authentication scaffolder for Next.js App Router, specifically designed for integrating with 3rd-party backend APIs.
Run one command. Get a complete, production-ready auth system in your project — fully typed, fully yours.
npx @smittdev/next-jwt-auth initWhen to use this vs. Auth.js
Auth.js (NextAuth) is an incredible library and the gold standard for OAuth integrations (Google, GitHub, Apple, etc.). If your Next.js application is your backend and you need OAuth, you should use Auth.js.
However, if you have a separate backend (Node, Go, Python, Java, etc.) that handles authentication and issues its own JWTs, wiring it into Next.js can be tricky. This library exists to solve that specific problem.
It bridges the gap between your Next.js frontend and your external API server by:
- Managing the short-lived access token + long-lived refresh token lifecycle.
- Silently refreshing tokens before they expire using Next.js Middleware.
- Automatically synchronizing the user's session between Server Components and Client Components.
Instead of fighting an opinionated session format, this library scaffolds the plumbing and gets out of your way. You implement three adapter functions (login, refreshToken, fetchUser) that fetch from your API, and you own the resulting session.
Philosophy
This is not an npm package you add as a dependency. It's a code scaffolder — like shadcn/ui, it copies a set of battle-tested TypeScript files into your project. You own the code from day one.
- No black boxes. No magic. Every line is in your codebase.
- Bring your own API. The library calls your adapter functions — you decide how tokens are issued and validated.
- No environment variables required. No secret keys managed by this library.
- Dual-token strategy (access + refresh) baked in.
- Full App Router support: Server Components, Server Actions, middleware, client hooks.
Quick Start
1. Scaffold
npx @smittdev/next-jwt-auth initYou'll be asked:
- Where to place the library (default:
lib/auth/orsrc/lib/auth/) - Whether to generate
middleware.ts(orproxy.tsfor Next.js 16+) - Whether to strip all comments from scaffolded files
- Whether to install
zod(the only peer dependency)
2. Implement your adapter and configure
Open the generated auth.ts at your project root. Fill in the three required adapter functions and optionally tune the configuration:
// auth.ts
import { Auth } from "@/lib/auth";
export const auth = Auth({
// ── Adapter (required) ───────────────────────────────────────────────────
// Three functions are required. They call your backend API — you decide the
// shape of the request and response. The library only cares about the return
// types: TokenPair ({ accessToken, refreshToken }) and SessionUser ({ id, email, ... }).
adapter: {
// Called by loginAction() with whatever credentials you pass from the client.
async login(credentials) {
const res = await fetch("https://your-api.com/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
if (!res.ok) throw new Error("Invalid credentials");
return res.json(); // must return { accessToken, refreshToken }
},
// Called automatically by middleware and fetchSession when the access token
// is expired or within the refresh threshold. Never called on the client.
//
// ⚠️ Race condition warning: if the user has multiple tabs open, two tabs
// can call refreshToken() concurrently with the same refresh token. If your
// backend uses rotate-on-use (single-use) refresh tokens, one request will
// succeed and the other will receive a 401 — invalidating the session in
// that tab. To handle this gracefully your backend should either:
// a) Accept the same refresh token within a short reuse window (~5s), or
// b) Return the same new token pair for duplicate in-flight requests.
// If you use long-lived, multi-use refresh tokens this is not an issue.
async refreshToken(refreshToken) {
const res = await fetch("https://your-api.com/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) throw new Error("Session expired");
return res.json(); // must return { accessToken, refreshToken }
},
// Called after login and during fetchSession to populate session.user.
// Return whatever user fields your app needs — extend SessionUser below
// via module augmentation to get full type safety.
async fetchUser(accessToken) {
const res = await fetch("https://your-api.com/me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error("Failed to fetch user");
return res.json(); // must return { id, email, ...any extra fields }
},
// Optional — called on logout to invalidate the refresh token server-side.
// If omitted, logout still clears cookies locally but skips the API call.
// Errors here are non-fatal — cookies are cleared regardless.
async logout({ refreshToken }) {
await fetch("https://your-api.com/auth/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
},
},
// ── Cookies (optional) ───────────────────────────────────────────────────
// All token cookies are httpOnly and never accessible to JavaScript.
// The `name` value is used as a base — two cookies are created:
// <name>.access — short-lived access token
// <name>.refresh — long-lived refresh token
cookies: {
name: "auth-session", // default: "auth-session"
secure: true, // default: true in production, false in development
sameSite: "lax", // default: "lax" — use "strict" for stricter CSRF protection
path: "/", // default: "/"
// domain: "example.com", // optional — set for cross-subdomain sharing
},
// ── Token refresh (optional) ─────────────────────────────────────────────
// Controls when the middleware proactively refreshes an access token before
// it expires. If the token has less than `refreshThresholdSeconds` remaining,
// the middleware calls adapter.refreshToken() and writes new cookies.
refresh: {
refreshThresholdSeconds: 60, // default: 60 (refresh when < 60s remain on the access token)
// Increase to e.g. 3600 to refresh proactively when < 1 hour remains on the access token.
},
// ── Pages (optional) ─────────────────────────────────────────────────────
// Redirect targets used by requireSession(), loginAction(), and logoutAction().
pages: {
signIn: "/login", // default: "/login" — requireSession() + logoutAction() redirect here
home: "/", // default: "/" — loginAction() redirects here
},
// ── Debug (optional) ─────────────────────────────────────────────────────
// Logs token refresh decisions, session resolution, middleware activity,
// and action outcomes to the server console. Keep off in production.
debug: process.env.NODE_ENV === "development",
});
// ── Extending the user type (optional) ──────────────────────────────────────
// Declare extra fields on SessionUser via module augmentation.
// These fields will be typed everywhere: server helpers, useSession(), middleware.
declare module "@/lib/auth" {
interface SessionUser {
name: string;
role: "admin" | "user";
avatarUrl?: string;
}
}3. Wrap your layout
// app/layout.tsx
import { auth } from "@/auth";
import { AuthProvider } from "@/lib/auth/client";
export default function RootLayout({ children }) {
return (
<html>
<body>
<AuthProvider actions={auth.actions}>{children}</AuthProvider>
</body>
</html>
);
}That's it. Auth is ready.
CLI Commands
init
Scaffolds the auth library into your project. Detects your project setup (Next.js version, TypeScript, package manager, tsconfig alias) and runs interactively.
npx @smittdev/next-jwt-auth initupdate
Updates the library files to the latest version without touching your auth.ts adapter implementation. Reports added, modified, and removed files.
npx @smittdev/next-jwt-auth update
# Preview what would change without writing any files
npx @smittdev/next-jwt-auth update --dry-runcheck
Validates your project setup. Runs up to eight checks and reports pass/warn/fail for each:
- Library directory is installed
auth.tsexists- Adapter functions are implemented (not stubs)
AuthProvideris present in the root layoutmiddleware.ts/proxy.tsexists and is configured correctly- Import alias in
auth.tsmatchestsconfig.json - OAuth route exists at
app/api/auth/[...oauth]/route.ts(only when OAuth is installed) adapter.oauthLoginis implemented (only when OAuth is installed)
npx @smittdev/next-jwt-auth checkExits with code 1 if any check fails.
uninstall
Removes the scaffolded auth files from your project. Interactively asks whether to delete the library directory, auth.ts, and middleware.ts / proxy.ts — so you can keep whatever you want.
npx @smittdev/next-jwt-auth uninstall
auth.tsdefaults to no when prompted — it contains your adapter implementation and is skipped unless you explicitly confirm.
add oauth
Adds OAuth provider support (Google, GitHub) to an existing installation. Run this after init.
npx @smittdev/next-jwt-auth add oauthThis command:
- Prompts you to select which providers to install (Google, GitHub, or both)
- Copies provider files into your library directory
- Generates the catch-all OAuth route at
app/api/auth/[...oauth]/route.ts - Shows a preview of the changes needed in
auth.tsand optionally patches them automatically
After running, you still need to:
- Register callback URLs with each provider (see OAuth Setup below)
- Add the required environment variables
- Implement
adapter.oauthLogin()in yourauth.ts
--version / --help
npx @smittdev/next-jwt-auth --version
npx @smittdev/next-jwt-auth --helpUsage
Server Components
import { auth } from "@/auth";
// Get session (returns null if unauthenticated)
const session = await auth.getSession();
// Require session (redirects to /login if unauthenticated)
const session = await auth.requireSession();
// Require session and append ?callbackUrl= to the redirect
const session = await auth.requireSession({ includeCallbackUrl: true });
// Individual token/user helpers
const user = await auth.getUser();
const accessToken = await auth.getAccessToken();
const refreshToken = await auth.getRefreshToken();Client Components
"use client";
import { useSession, useAuth } from "@/lib/auth/client";
function MyComponent() {
const session = useSession();
const { login, logout, fetchSession } = useAuth();
if (session.status === "loading") return <Spinner />;
if (session.status === "unauthenticated") return <LoginButton />;
// session.status === "authenticated"
return <p>Hello, {session.user.email}</p>;
}Login Form
"use client";
import { useAuth } from "@/lib/auth/client";
import { useRouter } from "next/navigation";
export function LoginForm() {
const { login } = useAuth();
const router = useRouter();
async function handleSubmit(e) {
e.preventDefault();
const result = await login({ email, password });
if (result.success) router.push("/dashboard");
else setError(result.error);
}
return <form onSubmit={handleSubmit}>...</form>;
}Pass redirect: false to handle navigation yourself, or callbackUrl to control the destination:
await login(credentials, { redirect: false });
await login(credentials, { callbackUrl: "/onboarding" });OAuth Login
Requires running
npx @smittdev/next-jwt-auth add oauthfirst.
OAuth Setup
1. Register callback URLs with each provider:
| Provider | Callback URL to register |
|----------|--------------------------|
| Google | https://your-domain.com/api/auth/google/callback |
| GitHub | https://your-domain.com/api/auth/github/callback |
- Google: Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client IDs → Authorized redirect URIs
- GitHub: Settings → Developer settings → OAuth Apps → your app → Authorization callback URL
2. Add environment variables:
# .env.local
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret3. Add providers and implement oauthLogin in auth.ts:
// auth.ts
import { Auth } from "@/lib/auth";
import { GoogleProvider, GitHubProvider } from "@/lib/auth/providers";
export const auth = Auth({
adapter: {
// ... your existing login, refreshToken, fetchUser ...
// Called after a successful OAuth callback.
// Exchange the provider's user profile for your own JWT tokens.
//
// @param provider - "google" | "github"
// @param userInfo - Normalized profile: { id, email, name, picture, raw }
// @param providerAccessToken - Raw access token from the provider.
// Forward to your backend if it needs to call provider APIs.
async oauthLogin(provider, userInfo, providerAccessToken) {
const res = await fetch("https://your-api.com/auth/oauth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, userInfo, providerAccessToken }),
});
if (!res.ok) throw new Error("OAuth login failed");
return res.json(); // must return { accessToken, refreshToken }
},
},
providers: [
new GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
new GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
});4. Add OAuth buttons to your login page:
"use client";
import { useAuth } from "@/lib/auth/client";
export function LoginPage() {
const { oauthLogin } = useAuth();
return (
<div>
<button onClick={() => oauthLogin("google")}>
Sign in with Google
</button>
<button onClick={() => oauthLogin("github")}>
Sign in with GitHub
</button>
{/* Redirect to /dashboard after login */}
<button onClick={() => oauthLogin("google", { callbackUrl: "/dashboard" })}>
Sign in with Google
</button>
</div>
);
}oauthLogin(provider, options?) redirects the browser to /api/auth/[provider]/login, which starts the OAuth flow. On success, the user is redirected to callbackUrl if provided, otherwise to pages.home.
How the OAuth flow works
Browser Next.js Provider
│ │ │
├─ GET /api/auth/google/login ──────────────────► │
│ │ generate state + PKCE │
│ │ store in httpOnly cookie│
│◄──────────── 302 redirect to Google ────────────┤
│ │ │
├─────────────────────────────────── user consents ┤
│ │ │
├─ GET /api/auth/google/callback?code=...&state=.. ┤
│ │ validate state (CSRF) │
│ │ validate PKCE verifier │
│ │ exchange code for token│
│ │ fetch user profile │
│ │ call adapter.oauthLogin│
│ │ set session cookies │
│◄──────────── 302 redirect to /dashboard ────────┤The OAuth flow uses both CSRF state and PKCE (S256) for security. The PKCE code_verifier is stored in an httpOnly cookie and never exposed to the browser.
Middleware / Route Protection
The generated middleware.ts (or proxy.ts on Next.js 16+) runs on the edge before every request. Use it for token refresh and coarse-grained routing — it is not a replacement for per-page auth checks.
Important Limitation: The Next.js middleware only runs when a page navigation happens or when users explicitly refresh the page. This library will silently refresh expired tokens dynamically during those requests. However, if you have long-lived client-side pages and make API requests with
axiosorfetch, the middleware will NOT run for those API requests. You must handle silent refreshes for client-side API calls inside an interceptor and then callupdateSessionToken(newToken)to sync the new token into the cookies so the rest of the app can see it.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
const resolveAuth = auth.createMiddleware();
export default async function middleware(request: NextRequest) {
const session = await resolveAuth(request);
const { pathname } = request.nextUrl;
const isProtected = auth.matchesPath(pathname, ["/dashboard/:path*", "/settings"]);
const isAuthPage = auth.matchesPath(pathname, ["/login", "/register"]);
// Redirect unauthenticated users away from protected routes
if (isProtected && !session.isAuthenticated) {
return session.redirect(new URL("/login", request.url));
}
// Redirect authenticated users away from auth pages
if (isAuthPage && session.isAuthenticated) {
return session.redirect(new URL("/dashboard", request.url));
}
return session.response(NextResponse.next());
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};Then guard individual pages with requireSession() inside the page itself:
// app/dashboard/page.tsx
import { auth } from "@/auth";
export default async function DashboardPage() {
// This calls your adapter's fetchUser — confirms the session is real and fresh
const session = await auth.requireSession();
return <p>Welcome, {session.user.email}</p>;
}Path pattern syntax:
| Pattern | Matches |
|---------|---------|
| /dashboard | Exact path only |
| /dashboard/:path* | /dashboard and all sub-routes |
| /user/:id | /user/123, /user/abc, etc. |
SSR Hydration
Pass initialSession from the server to eliminate the loading flash on first render:
// app/layout.tsx
import { auth } from "@/auth";
import { AuthProvider } from "@/lib/auth/client";
export default async function RootLayout({ children }) {
const session = await auth.getSession();
return (
<html>
<body>
<AuthProvider actions={auth.actions} initialSession={session}>
{children}
</AuthProvider>
</body>
</html>
);
}initialSession={session}— starts as"authenticated"with user data (no fetch on mount)initialSession={null}— starts as"unauthenticated"immediately (server confirmed no session)initialSessionomitted — starts as"loading", fetches on mount (default behavior)
Static rendering: If you want your layout to be statically rendered at build time (e.g. for a marketing site or a public-facing shell), do not call
auth.getSession()in the layout and pass it toAuthProvider. Reading cookies in the layout forces Next.js to opt the entire route into dynamic rendering. Instead, omitinitialSessionand let the client fetch the session on mount — your layout stays static and only the parts that need auth become dynamic.
Session Expiry Handling
Use onSessionExpired to react when a background revalidation discovers the session has ended:
<AuthProvider
actions={auth.actions}
onSessionExpired={() => {
toast.error("Your session expired. Please log in again.");
router.push("/login");
}}
>
{children}
</AuthProvider>Disable the automatic refresh-on-focus behavior if needed:
<AuthProvider actions={auth.actions} refreshOnFocus={false}>
{children}
</AuthProvider>Data Fetching Utilities
Run server-side data fetches that automatically receive the current session:
import { auth } from "@/auth";
// Run callback if session exists, return null otherwise
const data = await auth.withSession(async (session) => {
return fetchPublicFeed(session.user.id);
});
// Run callback or redirect to sign-in
const data = await auth.withRequiredSession(async (session) => {
return fetchProtectedData(session.accessToken);
});File Structure
After running init, your project will have:
auth.ts ← Your adapter + config (edit this)
middleware.ts ← Route protection (edit this; proxy.ts on Next.js 16+)
lib/auth/
metadata.json ← Install config (do not edit — used by CLI commands)
index.ts ← Auth() factory + all public exports
types.ts ← All TypeScript types
config.ts ← Global config singleton (internal)
core/
jwt.ts ← JWT decode + expiry utilities
cookies.ts ← httpOnly cookie helpers
config.ts ← Config builder + defaults
server/
session.ts ← getSession(), requireSession(), etc.
actions.ts ← Server Actions (login, logout, fetchSession)
fetchers.ts ← withSession(), withRequiredSession()
middleware/
auth-middleware.ts ← Middleware resolver + matchesPath()
client/
provider.tsx ← <AuthProvider>, useSession(), useAuth()
handlers/
index.ts ← Stub handler (replaced by add oauth)After running add oauth, the following are also added:
app/api/auth/[...oauth]/
route.ts ← OAuth catch-all route (do not edit)
lib/auth/
providers/
base.ts ← Abstract OAuthProvider base class (extend for custom providers)
google.ts ← GoogleProvider
github.ts ← GitHubProvider
index.ts ← Re-exports (only installed providers)
handlers/
oauth.ts ← PKCE + CSRF handler: login initiation + code exchange + callback
index.ts ← createOAuthHandler() exportAPI Reference
Auth(config) — auth.ts
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| adapter.login | (credentials) => Promise<TokenPair> | required | Authenticate and return tokens |
| adapter.refreshToken | (token) => Promise<TokenPair> | required | Exchange refresh token for new pair |
| adapter.fetchUser | (token) => Promise<SessionUser> | required | Return user data for an access token |
| adapter.logout | (tokens) => Promise<void> | optional | Invalidate refresh token server-side |
| adapter.oauthLogin | (provider, userInfo, providerAccessToken) => Promise<TokenPair> | optional | Exchange OAuth profile for your own JWT pair. Required when using OAuth providers |
| providers | OAuthProvider[] | [] | Provider instances. Add after running add oauth |
| cookies.name | string | "auth-session" | Cookie base name |
| cookies.secure | boolean | true in prod | Secure cookie flag |
| cookies.sameSite | string | "lax" | SameSite cookie attribute |
| refresh.refreshThresholdSeconds | number | 60 | Seconds before expiry to proactively refresh. Refresh triggers when this many seconds remain on the access token |
| pages.signIn | string | "/login" | Sign-in page — used by requireSession() and post-logout redirect |
| pages.home | string | "/" | Post-login redirect |
| debug | boolean | false | Log debug info to console |
Server Helpers
| Function | Returns | Description |
|----------|---------|-------------|
| auth.getSession() | Session \| null | Current session or null |
| auth.requireSession(opts?) | Session | Session or redirect to sign-in |
| auth.getUser() | SessionUser \| null | Current user or null |
| auth.getAccessToken() | string \| null | Current access token or null |
| auth.getRefreshToken() | string \| null | Current refresh token or null |
| auth.withSession(cb, default?) | TResult \| null | Run callback if authenticated |
| auth.withRequiredSession(cb) | TResult | Run callback or redirect |
Middleware
| Function | Returns | Description |
|----------|---------|-------------|
| auth.createMiddleware() | (req) => Promise<AuthMiddlewareResult> | Creates middleware resolver with auto token refresh |
| auth.matchesPath(pathname, patterns) | boolean | Match pathname against wildcard patterns |
AuthMiddlewareResult has:
isAuthenticated: boolean— valid, non-expired access token exists in cookieaccessToken: string \| nullrefreshToken: string \| nullresponse(base: NextResponse): NextResponse— applies refreshed cookies to responseredirect(url: URL): NextResponse— redirects and clears token cookies
Client Hooks
| Hook | Returns | Description |
|------|---------|-------------|
| useSession() | ClientSession | Reactive session state ("loading" / "authenticated" / "unauthenticated") |
| useAuth() | { login, logout, fetchSession, updateSessionToken, oauthLogin } | Auth action handlers. fetchSession syncs client state — silently rotates tokens if expired before returning. updateSessionToken allows injecting a new accessToken from outside the library (e.g. via an axios interceptor) and syncing it into the cookies. oauthLogin(provider, options?) redirects to the OAuth login route — provider is "google" \| "github", options.callbackUrl controls the post-login destination. |
<AuthProvider> Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| actions | AuthActions | required | Pass auth.actions from your auth.ts |
| initialSession | Session \| null \| undefined | undefined | Server session for SSR hydration |
| onSessionExpired | () => void | — | Called when background revalidation finds session gone |
| refreshOnFocus | boolean | true | Revalidate session when tab regains focus |
License
MIT
