zenyauth
v0.2.3
Published
Highly efficient Next.js OAuth and auth framework, with rate limits and usage credits
Readme
ZenyAuth Walkthrough
ZenyAuth is a small auth layer for Next.js that keeps the core session state in signed cookies and exposes the same session snapshot on the server, in React, and in route handlers.
It is intentionally not a database. If your app needs to create user records, keep an imageUrl, store roles, or sync profile changes, use your own datastore, such as Redis, and wire it in through the auth callbacks.
The main idea is:
- Define a single auth config with providers and session options.
- Reuse that config in Next.js route handlers, server helpers, and React components.
- Let the library manage the session cookie, OAuth flow, and client hydration.
This document walks through how the package is structured, how the pieces fit together, and how to use it in a real app.
What The Package Exports
The package is split into a few entry points:
zenyauthzenyauth/reactzenyauth/nextzenyauth/providers/githubzenyauth/providers/googlezenyauth/providers/microsoftzenyauth/providers/applezenyauth/providers/email
At the root, you define auth configuration:
import { createAuth, defineAuth } from "zenyauth";The root package also exports the limiter primitives:
import { RateLimiter, UsageLimiter } from "zenyauth";From zenyauth/react, you get client-side session access:
import { SessionProvider, useSession, createReactAuth, Session } from "zenyauth/react";From zenyauth/next, you get the Next.js integration:
import { createNextAuth, SessionProvider, Session, withAuth, getServerSession } from "zenyauth/next";Mental Model
ZenyAuth stores the authenticated user in a signed JWT cookie. A session snapshot looks like this:
type SessionSnapshot<TUser> = {
user: TUser | undefined;
expiryDate: Date | undefined;
isExpired: boolean;
isValid: boolean;
};That snapshot is used everywhere:
- On the server, it is read from cookies.
- In React, it is hydrated into a client store.
- In middleware or route guards, it is attached to the request and passed to authorization callbacks.
The package supports two provider types:
- OAuth providers
- Email providers
OAuth providers implement the redirect, callback, token exchange, and profile fetch flow. Email providers accept credentials directly and return a user payload.
If you want to persist app-specific data, the usual pattern is:
- Use
callbacks.signInto create or update the user record in Redis or another store. - Use
callbacks.sessionPayloadto read that stored record and shape the session user. - Keep the cookie session small and treat Redis as the source of truth for app data.
How The Flow Works
Sign In
When a sign-in request hits the auth route:
- ZenyAuth identifies the provider from the URL.
- If the provider is OAuth, it generates a state token and optionally a PKCE verifier.
- It writes a short-lived flow cookie.
- It redirects the browser to the provider authorization URL.
Callback
When the provider redirects back:
- ZenyAuth reads the flow cookie.
- It validates the OAuth
state. - It exchanges the authorization code for tokens.
- It fetches the user profile.
- It maps the profile to a user payload.
- It signs a session JWT and sets the session cookie.
Session Access
After sign-in:
- The server reads the session cookie and verifies the JWT.
- The auth layer updates a readable snapshot cookie with the decoded session payload.
- The Next.js app hydrates the client from that snapshot cookie.
- React components read the current session from an external store.
- Cross-tab updates are synced with
BroadcastChannelwhen available.
OAuth Provider Setup
ZenyAuth handles the OAuth redirect and callback flow for you, but each provider still needs an app registration with the correct redirect URI.
The callback URL is always built from your auth base path and provider id. For the default setup, register these local URLs so you can test on your machine:
http://localhost:3000/api/authorize/callback/google
http://localhost:3000/api/authorize/callback/microsoft
If you change basePath, replace /api/authorize with your custom value.
Google OAuth
- Open the Google Cloud Console and create or select a project.
- Go to APIs and Services > Credentials.
- Create an OAuth client ID.
- Choose
Web applicationas the application type. - Add these Authorized redirect URIs:
http://localhost:3000/api/authorize/callback/google
https://your-production-domain.com/api/authorize/callback/google
- Copy these environment variables into your app:
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
Google requires the redirect URI to match exactly. For local development, http://localhost:3000/... is allowed, so you can test sign-in on your machine before deploying.
Microsoft Entra OAuth
- Open the Microsoft Entra admin center.
- Go to App registrations and create a new app, or open an existing one.
- Open Authentication.
- Under Platform configurations, add a
Webplatform. - Add these Redirect URIs:
http://localhost:3000/api/authorize/callback/microsoft
https://your-production-domain.com/api/authorize/callback/microsoft
- Copy these environment variables into your app:
MICROSOFT_CLIENT_ID
MICROSOFT_CLIENT_SECRET
- Optional: set
MICROSOFT_TENANT_IDif you want to lock the app to a single tenant. If you omit it, the provider usescommon.
For local testing, Microsoft Entra also accepts http://localhost:3000/... redirect URIs on the Web platform, so you can run the app locally and sign in without deploying first.
Step 1: Define Auth Config
Create a shared auth config file, usually something like src/auth.ts.
Example:
import { createAuth } from "zenyauth";
import GoogleProvider from "zenyauth/providers/google";
import GithubProvider from "zenyauth/providers/github";
export const auth = createAuth({
secret: process.env.AUTH_SECRET!,
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
}),
GithubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!
})
],
session: {
maxAge: 60 * 60 * 24 * 30
},
callbacks: {
sessionPayload: async (user) => {
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image
};
}
}
});What createAuth Does
createAuth is just a typed helper around your auth options. It preserves the user type so later helpers can infer it automatically.
You can also use defineAuth; it is the same function.
Config Options
The important options are:
secret: used to sign and verify the session JWT and flow cookie.providers: array of OAuth or email providers.basePath: defaults to/api/authorize.session.maxAge: defaults to 30 days.session.cookiePrefix: defaults toza.pages.signInandpages.error: optional redirect pages.callbacks.signIn: called after a provider sign-in succeeds.callbacks.sessionPayload: maps a provider payload to your app user type.
Persist App Data In Redis
If you want the whole app to share one user record, a Redis-backed repository is a good fit for a small to medium Next.js app. ZenyAuth can authenticate the user, then your callbacks can upsert the payload into Redis and read it back when building the session snapshot.
This is a good place to store:
idemailnameimageUrlrolelastLoginAt- App-specific flags and counters
Example Redis Layer
// src/lib/user-store.ts
import { createClient } from "redis";
type UserRecord = {
id: string;
email: string;
name?: string;
imageUrl?: string;
role?: "admin" | "member";
lastLoginAt?: string;
createdAt: string;
updatedAt: string;
};
const redis = createClient({
url: process.env.REDIS_URL
});
const ready = redis.connect();
async function ensureRedis(): Promise<void> {
await ready;
}
export async function getUserRecord(userId: string): Promise<UserRecord | null> {
await ensureRedis();
const raw = await redis.get(`user:${userId}`);
return raw ? (JSON.parse(raw) as UserRecord) : null;
}
export async function upsertUserRecord(input: {
id: string;
email: string;
name?: string;
imageUrl?: string;
role?: "admin" | "member";
lastLoginAt?: string;
}): Promise<UserRecord> {
await ensureRedis();
const existing = await getUserRecord(input.id);
const record: UserRecord = {
id: input.id,
email: input.email,
name: input.name ?? existing?.name,
imageUrl: input.imageUrl ?? existing?.imageUrl,
role: input.role ?? existing?.role ?? "member",
lastLoginAt: input.lastLoginAt ?? existing?.lastLoginAt,
createdAt: existing?.createdAt ?? new Date().toISOString(),
updatedAt: new Date().toISOString()
};
await redis.set(`user:${input.id}`, JSON.stringify(record));
return record;
}Wire It Into ZenyAuth
// src/auth.ts
import { createAuth } from "zenyauth";
import GoogleProvider from "zenyauth/providers/google";
import { getUserRecord, upsertUserRecord } from "@/lib/user-store";
type AppUser = {
id: string;
email: string;
name?: string;
imageUrl?: string;
role: "admin" | "member";
lastLoginAt?: string;
};
export const auth = createAuth<AppUser>({
secret: process.env.AUTH_SECRET!,
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
})
],
callbacks: {
signIn: async ({ user }) => {
const userId = user.id ?? user.email;
await upsertUserRecord({
id: userId,
email: user.email,
name: user.name,
imageUrl: user.image,
lastLoginAt: new Date().toISOString()
});
},
sessionPayload: async (user) => {
const userId = user.id ?? user.email;
const stored = await getUserRecord(userId);
return {
id: userId,
email: user.email,
name: stored?.name ?? user.name,
imageUrl: stored?.imageUrl ?? user.image,
role: stored?.role ?? "member",
lastLoginAt: stored?.lastLoginAt
};
}
}
});With this setup:
- The provider returns the raw profile.
signIncreates or updates the Redis record.sessionPayloadreads the Redis record and turns it into the app user.Session.user(auth)anduseSession()both see the same shape.
Updating imageUrl Later
When a user changes their avatar, update Redis first and let the next session read pick it up:
await upsertUserRecord({
id: userId,
email: user.email,
imageUrl: "https://cdn.example.com/new-avatar.png"
});This keeps the session cookie lean while the app-specific profile data lives in Redis.
Rate Limiting And Usage Credits
ZenyAuth now also ships two framework-agnostic limiter primitives:
RateLimiterUsageLimiter
The design is intentionally adapter-driven. ZenyAuth defines the request and response schema, but your application decides how to read and write limiter state in your own database, cache, or queue.
That means you can plug in whatever storage you already use:
- DynamoDB
- Prisma
- MongoDB
- Upstash Redis
- SQL
- Custom in-memory logic for tests
The library does not store credits or counters for you. It only normalizes the input and output shape and handles timeout or fallback behavior.
RateLimiter
Use RateLimiter when you want to enforce a request budget over a time window.
import { RateLimiter } from "zenyauth";
const limiter = new RateLimiter({
namespace: "auth:signin",
limit: 5,
duration: "1m",
adapter: {
async limit(input) {
// You own the storage and the algorithm here.
// Read and write whatever collection/table/cache you want.
return {
success: true,
limit: input.limit,
remaining: 4,
reset: input.now + input.durationMs,
reason: "allowed"
};
}
}
});
const result = await limiter.limit({
identifier: "user_123",
cost: 1
});The adapter receives a normalized input object with:
namespaceidentifierkeylimitdurationMscostnowmeta
The result shape is also normalized:
successlimitremainingresetreason
UsageLimiter
Use UsageLimiter when you want to track consumable credits, not just requests.
import { UsageLimiter } from "zenyauth";
const usage = new UsageLimiter({
namespace: "ai:generation",
limit: 10_000,
refill: {
amount: 10_000,
interval: "30d"
},
adapter: {
async consume(input) {
// The library gives you the shape.
// You decide how credits are persisted, decremented, and refilled.
return {
success: true,
limit: input.limit,
remaining: 9_750,
used: 250,
reset: input.now + input.refill!.intervalMs,
reason: "allowed"
};
}
}
});
const result = await usage.consume({
identifier: "org_123",
bucket: "starter",
cost: 250
});The adapter receives:
namespaceidentifierbucketkeylimitcostnowrefillmeta
The result shape includes:
successlimitremainingusedresetreason
Error And Timeout Behavior
Both limiters support the same control flow around adapter failures:
- Set
failureMode: "closed"to deny when your adapter fails. - Set
failureMode: "open"to allow when your adapter fails. - Set
timeoutto guard slow adapters. - Provide
timeout.fallbackif you want a custom fallback result. - Provide
onErrorif you want to transform thrown adapter errors into a result.
That keeps the library strict about schema while leaving the database strategy entirely in your hands.
Step 2: Wire Next.js Route Handlers
Use createNextAuth in your auth route file.
If you are using the App Router, create:
// app/api/authorize/[...zenyauth]/route.ts
import { auth } from "@/auth";
import { createNextAuth } from "zenyauth/next";
const zenyauth = createNextAuth(auth);
export const GET = zenyauth.GET;
export const POST = zenyauth.POST;What The Route Handles
The generated handler supports these actions:
GET /api/authorize/providersGET /api/authorize/sessionGET /api/authorize/errorGET or POST /api/authorize/signin/:providerGET or POST /api/authorize/callback/:providerPOST /api/authorize/signout
The route parser is strict. Unknown segments return a 404-style auth error.
Step 3: Hydrate React From The Server
To keep server and client in sync, wrap your app with SessionProvider.
Example:
// app/layout.tsx
import type { ReactNode } from "react";
import { createNextAuth } from "zenyauth/next";
import { auth } from "@/src/auth";
const zenyauth = createNextAuth(auth);
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<zenyauth.SessionProvider>{children}</zenyauth.SessionProvider>
</body>
</html>
);
}Why This Exists
The proxy and auth handlers keep two cookies in sync:
- An HTTP-only signed JWT cookie
- A readable snapshot cookie with the decoded session payload
SessionProvider reads that snapshot cookie once on initial load and hydrates the client store from it. That avoids a second fetch on first render while still keeping the JWT itself hidden from client JavaScript.
If you are not using Next.js, or you want to hydrate manually, use the React SessionProvider directly and pass initialSnapshot yourself.
Step 4: Read Session In React
Use useSession in client components:
"use client";
import { useSession } from "zenyauth/react";
export function UserMenu() {
const session = useSession();
if (!session.isValid) {
return <a href="/login">Sign in</a>;
}
return (
<div>
<p>{session.user?.email}</p>
<button onClick={() => session.signOut({ callbackUrl: "/" })}>Sign out</button>
</div>
);
}The hook returns both the snapshot fields and the actions:
userexpiryDateisExpiredisValidsignIn(provider, options)signOut(options)
Step 5: Read Session On The Server
Use the server helper when you need auth state in server components, route handlers, or server actions.
import { auth } from "@/auth";
import { Session } from "zenyauth/next";
export async function GET() {
const user = await Session.user(auth);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
return Response.json({ user });
}You can also read the full snapshot:
const snapshot = await Session.read(auth);The same helpers exist for:
Session.user(auth)Session.expiryDate(auth)Session.isExpired(auth)Session.isValid(auth)
Step 6: Protect Routes With withAuth
For route protection, use withAuth.
// middleware.ts
import { auth } from "@/auth";
import { withAuth } from "zenyauth/next";
export default withAuth(auth, undefined, {
pages: {
signIn: "/login"
},
callbacks: {
authorized: ({ session }) => {
return session.isValid;
}
}
});
export const config = {
matcher: ["/dashboard/:path*"]
};If the request is unauthorized and the request expects HTML, ZenyAuth redirects to the configured sign-in page with a callbackUrl.
If the handler returns a response, it can also pass through the auth snapshot:
import { auth } from "@/auth";
import { withAuth } from "zenyauth/next";
export default withAuth(auth, async (req) => {
if (!req.auth.isValid) {
return new Response("Unauthorized", { status: 401 });
}
return Response.json({ email: req.auth.user?.email });
});The request is also decorated with a serialized session header for downstream middleware and server code.
Step 7: Trigger Sign In And Sign Out
The client-side API mirrors the server helpers.
Sign In
"use client";
import { useSession } from "zenyauth/react";
export function LoginButton() {
const session = useSession();
return (
<button onClick={() => session.signIn("google", { callbackUrl: "/dashboard" })}>
Sign in with Google
</button>
);
}The provider argument must match the provider id, such as google, github, apple, microsoft, or your custom provider id.
For OAuth providers, signIn starts the redirect flow. For email providers, it posts the credentials and returns a session response.
Sign Out
"use client";
import { useSession } from "zenyauth/react";
export function LogoutButton() {
const session = useSession();
return (
<button onClick={() => session.signOut({ callbackUrl: "/" })}>
Sign out
</button>
);
}Built-In Providers
ZenyAuth ships with provider factories for common providers.
import GoogleProvider from "zenyauth/providers/google";
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
});Google uses:
openid,profile, andemailscopesstateandpkcechecksuserinfoto fetch the profile
GitHub
import GithubProvider from "zenyauth/providers/github";GitHub uses:
read:useruser:email- A separate
/user/emailsfetch so it can resolve a verified email address
Microsoft
import MicrosoftProvider from "zenyauth/providers/microsoft";You can pass an optional tenantId. If omitted, it uses common.
Apple
import AppleProvider from "zenyauth/providers/apple";Apple uses:
statepkceresponse_mode=form_post
import EmailProvider from "zenyauth/providers/email";An email provider is not magic. You provide the credential check yourself:
import EmailProvider from "zenyauth/providers/email";
EmailProvider({
authorize: async (credentials) => {
const email = String(credentials.email ?? "");
const password = String(credentials.password ?? "");
if (email === "[email protected]" && password === "secret") {
return {
id: "alice",
email,
name: "Alice"
};
}
return null;
}
});Custom User Types
The package is typed so you can define your own user object instead of using the default { email, name?, image? }.
Example:
import { createAuth } from "zenyauth";
import GoogleProvider from "zenyauth/providers/google";
type AppUser = {
id: string;
email: string;
role: "admin" | "member";
};
export const auth = createAuth<AppUser>({
secret: process.env.AUTH_SECRET!,
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
})
],
callbacks: {
sessionPayload: async (user) => ({
id: user.id ?? user.email,
email: user.email,
role: "member"
})
}
});That type will flow into:
Session.read(auth)Session.user(auth)createNextAuth(auth).SessioncreateReactAuth(auth).useSession()
How The Internals Fit Together
Session Cookie
The session is stored in two cookies with a configurable prefix.
The default cookie names are:
za.sessionza.snapshotza.flow.<providerId>
The session cookie contains a signed JWT with:
subprovideruseriatexp
Flow Cookie
OAuth sign-in uses a short-lived flow cookie to preserve:
providerstatecallbackUrlcodeVerifierif PKCE is enabled
That prevents the callback from being accepted unless it matches the original sign-in request.
Client Store
On the client, the library keeps one shared in-memory session store.
It:
- Hydrates from the server snapshot
- Subscribes React components with
useSyncExternalStore - Revalidates across tabs with
BroadcastChannelwhen available - Marks the snapshot expired when the expiry timer runs out
Full Minimal Example
Here is the smallest realistic setup.
// auth.ts
import { createAuth } from "zenyauth";
import GoogleProvider from "zenyauth/providers/google";
export const auth = createAuth({
secret: process.env.AUTH_SECRET!,
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
})
]
});// app/api/authorize/[...zenyauth]/route.ts
import { auth } from "@/auth";
import { createNextAuth } from "zenyauth/next";
const zenyauth = createNextAuth(auth);
export const GET = zenyauth.GET;
export const POST = zenyauth.POST;// app/layout.tsx
import type { ReactNode } from "react";
import { SessionProvider } from "zenyauth/next";
export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
}// app/login/page.tsx
"use client";
import { useSession } from "zenyauth/react";
export default function LoginPage() {
const session = useSession();
return (
<button onClick={() => session.signIn("google", { callbackUrl: "/dashboard" })}>
Sign in
</button>
);
}// app/dashboard/page.tsx
import { auth } from "@/auth";
import { Session } from "zenyauth/next";
export default async function DashboardPage() {
const user = await Session.user(auth);
if (!user) {
return <p>Unauthorized</p>;
}
return <pre>{JSON.stringify(user, null, 2)}</pre>;
}Practical Notes
secretmust be stable across server instances, or existing sessions will fail verification.basePathshould match the route where you mounted the handler.- OAuth provider callback URLs must match what you configured at the identity provider.
- The package expects a browser for client store hydration and cross-tab sync.
- The signed session JWT cookie is HTTP-only, and the readable snapshot cookie is only used for client hydration.
Summary
Use ZenyAuth when you want:
- A typed auth config shared across server and client
- Cookie-backed sessions with no client session fetch on first render
- OAuth and email provider support
- Next.js helpers for route handlers, middleware, server components, and React hooks
- A clean place to sync auth payloads into Redis or another datastore
The recommended usage path is:
- Define
auth - Mount
createNextAuth(auth)on/api/authorize/[...zenyauth] - Wrap the app in
SessionProvider - Read session with
useSession()in client components - Read session with
Session.read(auth)orSession.user(auth)on the server - Use
callbacks.signInandcallbacks.sessionPayloadto persist and hydrate app-specific user data
