Skip to content

Authentication System

Authentication is the half of Auther that proves who a request is from. Before any permission can be computed, a user has to sign in, exchange a code for a token, and have that token signed by a key the rest of the world can trust.

Auther speaks OIDC (OpenID Connect — the thin identity layer on top of OAuth2 that standardizes login and turns “who is this user” into a signed token any app can read). This chapter walks through how Auther wires all of that together with better-auth, and the handful of deliberate choices that keep it secure and edge-friendly.

The central auth configuration lives in one file. Everything Auther does at the authentication layer — email/password, usernames, admin roles, API keys, JWT issuance, the OIDC provider surface — is assembled by composing better-auth plugins into a single betterAuth() instance.

src/lib/auth.ts
betterAuth({
// ...plugins listed below
});
PluginPurpose
emailAndPasswordTraditional email/password sign-in with email verification
usernameAdds username field to user accounts
adminAdmin role management (role field on user table)
apiKeyFirst-class API key management (create, verify, revoke, rate-limit)
jwtJWT issuance with configurable issuer and audience
oidcProviderFull OIDC provider surface (authorize, token, userinfo, jwks)
oAuthProxyHandles URL differences between dev/staging/production deployments
nextCookiesCookie-based session management for the Next.js admin UI

Every auth endpoint is served through a single catch-all route handler at src/app/api/auth/[...betterAuth]/route.ts. That one route does two extra jobs beyond delegating to better-auth: it wraps every request with CORS headers derived from the configured trusted origins, and it records metrics — latency and request counts — broken down by OIDC route type (authorize, token, userinfo, jwks, sign-in, sign-up, sign-out). Centralizing this means CORS and observability are handled once rather than re-implemented per endpoint.

What makes Auther an identity provider rather than just a login form is the oidcProvider plugin. It exposes the standard OAuth2/OIDC endpoints that any conforming client knows how to talk to, so adopting Auther doesn’t require a custom client SDK.

EndpointMethodPurpose
/api/auth/oauth2/authorizeGETOAuth2 authorization endpoint. Redirects the user to sign-in if not authenticated, then redirects back to the client with an authorization code.
/api/auth/oauth2/tokenPOSTToken exchange. Accepts authorization codes (with an optional PKCE verifier) and returns access tokens and ID tokens (JWTs).
/api/auth/oauth2/userinfoGETReturns authenticated user profile data. Requires a valid access token.
/api/auth/jwksGETJSON Web Key Set. Exposes the public keys for JWT signature verification, so consuming apps can validate tokens without sharing secrets.
/api/auth/oauth2/registerPOSTDynamic client registration. Protected by the internal signup secret.

Most of these endpoints cooperate in the authorization-code flow — OAuth2’s standard login dance. The mental model: the user signs in on Auther (never on the client), Auther hands the client a short-lived code via redirect, and the client swaps that code for tokens over a back channel. The code is useless on its own, so it can safely travel through the browser’s URL.

Authorization-code flow
Rendering diagram…

The provider is configured in the same central auth file:

src/lib/auth.ts
oidcProvider({
loginPage: "/sign-in",
allowDynamicClientRegistration: true,
useJWTPlugin: true,
metadata: {
issuer: env.JWT_ISSUER, // e.g., "https://auth.example.com"
},
trustedClients: [payloadAdminClient, payloadSPAClient],
}),

Three options carry most of the meaning. loginPage: "/sign-in" is where an unauthenticated user landing on the authorize endpoint gets redirected. allowDynamicClientRegistration: true lets new OAuth clients register themselves programmatically — guarded by the signup secret so it isn’t an open door. useJWTPlugin: true routes ID-token signing through the JWT plugin, which means RS256 with JWKS-managed keys rather than a shared symmetric secret.

Auther carries two distinct session models at once, because two very different kinds of caller need to be authenticated: operators clicking around the admin dashboard, and external applications consuming tokens over HTTP.

Users interacting with the admin dashboard directly are authenticated with cookie-based sessions. A small set of helpers makes the three common checks ergonomic, all built on auth.api.getSession(), which reads the session cookie from the incoming request headers.

src/lib/session.ts
// Get current session (returns null if not authenticated)
const session = await getSession();
// Require authentication (throws if not logged in)
const session = await requireAuth();
// Require admin role (throws if not admin)
const session = await requireAdmin();

A second layer protects whole route trees. src/proxy.ts runs as Next.js edge middleware and intercepts requests to /admin/* and /dashboard/*: if there’s no session cookie and the route is protected, the request is redirected to sign-in with the original path preserved as redirectTo.

src/proxy.ts
const AUTH_ROUTES = ["/admin", "/dashboard"];
// If no session cookie and route is protected, redirect to sign-in
if (!sessionCookie && isProtected) {
const signInUrl = new URL("/sign-in", request.url);
signInUrl.searchParams.set("redirectTo", pathname);
return NextResponse.redirect(signInUrl);
}

External applications never get a cookie. They receive JWTs through the OIDC flow, signed with RS256 using rotating JWKS keys, and those tokens carry embedded permissions so the consuming app can authorize most requests without calling back to Auther. How those permissions are computed and embedded is the subject of ReBAC & the Permission Engine.

A JWKS (JSON Web Key Set) is the public-key directory clients fetch from /api/auth/jwks: think of it as a phone book of signing keys. A client looks up the key once, caches it, and from then on verifies token signatures offline — no round-trip to Auther per request, and no shared secret to leak.

Signing keys are a single point of failure: anyone who steals the private key can forge tokens for every client. Auther limits that blast radius by rotating its RS256 keys on a schedule — retiring the old key and minting a new one so a leaked key only forges tokens for a bounded window. The rotation logic lives in src/lib/jwks-rotation.ts.

ParameterValueConstant
Rotation interval30 daysJWKS_ROTATION_INTERVAL_MS
Retention window60 daysJWKS_RETENTION_WINDOW_MS

The rotation itself is driven by a cron job:

  1. A cron job calls POST /api/internal/rotate-jwks, protected by CRON_SECRET.

  2. rotateJwksIfNeeded() checks the age of the latest key.

  3. If that key is older than 30 days (or none exists yet), a fresh RS256 key pair is generated.

  4. Keys older than 60 days are pruned from the database.

  5. Private keys are encrypted at rest with AES-256-GCM, using BETTER_AUTH_SECRET as the encryption key.

  6. The jwks table stores both the encrypted private key and the public key.

The 60-day retention window is the reason rotation doesn’t break live clients: a token signed by the previous key stays verifiable through the overlap period.

From the application’s perspective the JWKS repository (src/lib/repositories/jwks-repository.ts) is read-only. It offers findAll(), findAllWithStatus() — which computes each key’s age and an “ok”/“breached” status — and findLatest(). Actually minting new keys is handled internally by better-auth’s createJwk() during rotation, so application code never creates keys directly.

Most clients can register dynamically, but the two reference clients ship pre-configured from environment variables in src/lib/auth.ts. They’re worth studying because they illustrate the two ends of the OAuth client spectrum: a confidential client (a server-side app that can keep a secret out of users’ hands) and a public client (a browser app, where any “secret” ships in JavaScript and is therefore no secret at all).

The confidential client is for server-side apps that can safely store a secret:

src/lib/auth.ts
const payloadAdminClient = {
clientId: env.PAYLOAD_CLIENT_ID,
clientSecret: env.PAYLOAD_CLIENT_SECRET,
type: "web",
name: "Payload Admin (Confidential)",
redirectURLs: [env.PAYLOAD_REDIRECT_URI],
metadata: {
tokenEndpointAuthMethod: "client_secret_basic",
grantTypes: ["authorization_code"],
},
skipConsent: true,
};

It authenticates with client_secret_basic — a Base64-encoded client_id:client_secret sent in the Authorization header as `Basic ${creds}` — and uses the authorization code flow. Setting skipConsent: true means users aren’t shown an approval prompt; once authenticated they’re redirected straight back to the client.

The public client is for SPAs and other browser apps, which cannot hide a secret:

src/lib/auth.ts
const payloadSPAClient = {
clientId: env.PAYLOAD_SPA_CLIENT_ID,
type: "public",
name: "Payload SPA (PKCE)",
redirectURLs: env.PAYLOAD_SPA_REDIRECT_URIS,
metadata: {
tokenEndpointAuthMethod: "none",
grantTypes: ["authorization_code"],
postLogoutRedirectUris: env.PAYLOAD_SPA_LOGOUT_URIS,
},
skipConsent: true,
};

Because it has no secret, this client enforces PKCE (Proof Key for Code Exchange). The browser generates a code_verifier/code_challenge pair, sends the challenge during authorization, and proves possession of the verifier during token exchange. That binding is what stops an attacker who intercepts the authorization code from redeeming it.

Beyond these two, additional clients can be registered at runtime through the /api/auth/oauth2/register endpoint or via the admin UI at /admin/clients/register.

Auther is a centralized IdP for known applications, not a public sign-up service, so open self-registration would be a liability. By default, public user registration is restricted. A createAuthMiddleware before-hook in src/lib/auth.ts runs ahead of every auth request and gates the registration paths:

src/lib/auth.ts
validateInternalSignupAccess(
request,
relativePath,
restrictedSignupPaths, // Set(["/sign-up/email", "/oauth2/register"])
env.PAYLOAD_CLIENT_SECRET
);

The validateInternalSignupAccess() function in src/lib/utils/auth-middleware.ts checks that requests to /sign-up/email and /oauth2/register carry an x-internal-signup-secret header matching PAYLOAD_CLIENT_SECRET. If it’s missing or wrong, the request gets a 403 Forbidden.

In practice this means:

  • Users cannot self-register unless the calling application supplies the internal secret.
  • New users are typically created through the admin UI, invite links, or an authorized backend service.

Two transactional emails round out the authentication lifecycle. Both are sent through Resend (src/lib/email/send.ts) using React Email templates in src/lib/email/templates/:

  • Verification email — sent on sign-up via sendVerificationEmail(), from the email-verification.tsx template.
  • Password reset email — sent via sendPasswordResetEmail(), from the password-reset.tsx template.

Both functions follow the same shape: render the React Email template to HTML with render(), send it through the Resend API, and record metrics (duration, success/error counts, rate-limit detection). For tests, a SKIP_EMAIL_SENDING environment variable short-circuits actual delivery.

The relevant configuration:

  • RESEND_API_KEY — the Resend API key.
  • EMAIL_FROM — the sender address, e.g. noreply@example.com.
  • EMAIL_FROM_NAME — the sender display name, e.g. "Auther".