Technology & Architecture
12 min read

Building an Opinionated Authentication Foundation for Next.js and PostgreSQL

A technical overview of `@tgoliveira/secure-auth`: an opinionated authentication foundation for Next.js, PostgreSQL, and Drizzle, designed to reduce repeated auth work across projects while keeping clear boundaries, security controls, and honest experimental limitations.

Building an Opinionated Authentication Foundation for Next.js and PostgreSQL

GitHub: github.com/tgoliveira11/next-secure-auth-starter Package: @tgoliveira/secure-auth

Authentication is one of those parts of a product that looks simple until it is not.

At first, it is “just login”. Then comes registration, password reset, email verification, OAuth, account linking, sessions, revocation, password policy, rate limiting, audit logs, two-factor authentication, passkeys, account deletion, and the inevitable question: are we doing this safely enough?

For many Next.js applications, especially internal tools, SaaS products, and early-stage platforms, this work gets rebuilt again and again. Sometimes it starts as a starter app. Sometimes it grows organically inside the product. Sometimes it becomes a collection of copied files passed from one repository to another.

@tgoliveira/secure-auth is an attempt to solve that problem in a more deliberate way.

It is an opinionated, experimental authentication package for Next.js App Router, TypeScript, PostgreSQL, and Drizzle ORM. Its goal is not to be a universal authentication platform. Its goal is narrower: provide a reusable account-authentication foundation for teams that are already building in this stack and do not want to rewrite the same security-sensitive flows in every application.


The problem: authentication is rarely just authentication

A modern product rarely needs only a username and password form.

Even a relatively small application may need:

Area Common implementation work
Credentials Register, login, password hashing, password reset, password change
OAuth Provider configuration, callbacks, account linking, error handling
Email flows Verification links, reset links, resend throttling
Sessions Server-side session records, revocation, device metadata
Security controls Rate limits, audit logs, token hashing, safe logging
2FA TOTP setup, recovery codes, verification during login
Passkeys WebAuthn challenges, credential storage, browser-side flows
UI Login, register, forgot password, account settings, sessions, security settings
Database Auth schema, migrations, indexes, repositories

None of these things are impossible to build. The issue is that they are easy to build inconsistently.

One product gets a better password policy. Another forgets token hashing. One app has session revocation. Another only supports logout from the current browser. One team fixes an account enumeration risk, but the fix never reaches the other codebases.

That is the real cost: not just the first implementation, but the maintenance drift across products.


What @tgoliveira/secure-auth is

@tgoliveira/secure-auth is a package-first authentication layer built around a single composition root:

createSecureAuth(config)

The consumer application provides infrastructure:

  • the PostgreSQL database connection;
  • the Drizzle client;
  • secrets and environment mapping;
  • OAuth configuration;
  • WebAuthn configuration;
  • the email transport;
  • route and page mounting inside the Next.js app.

The package provides the authentication domain:

  • schema and migrations;
  • services and repositories;
  • API route handlers;
  • password policy;
  • email verification;
  • password reset;
  • OAuth integration through NextAuth v4;
  • passkeys through WebAuthn;
  • TOTP 2FA and backup codes;
  • session management;
  • audit events;
  • rate limiting;
  • optional ready-made UI pages and components.

In practical terms, the application owns deployment-specific concerns, while the package owns the reusable authentication behavior.

That boundary is the core idea.


Why not just keep a starter app?

A starter app is useful at the beginning. It proves that the flows work. It gives you something concrete to copy from. It can show how the architecture is supposed to look.

But a starter app has a major limitation: once copied, it stops being a shared system.

Every consumer becomes its own fork. Security fixes do not automatically propagate. A password-reset improvement has to be manually copied. A 2FA fix may land in one app but not another. Documentation starts drifting from implementation.

That is why secure-auth moved toward a package-first model.

The repository can still contain a starter app, but the starter is no longer the product. The package is the product. The starter and demo apps exist to validate that a real consumer can integrate the package using public exports only.

That distinction matters.

A starter helps you begin once. A package helps you maintain many times.


The architecture in one picture

Conceptually, the integration looks like this:

┌──────────────────────────────┐       ┌──────────────────────────────┐
│ Consumer Next.js app          │       │ @tgoliveira/secure-auth       │
├──────────────────────────────┤       ├──────────────────────────────┤
│ PostgreSQL + Drizzle client   │ ─db─▶ │ Services and repositories      │
│ Environment mapping           │ ─cfg▶ │ createSecureAuth(config)       │
│ EmailProvider implementation  │ ─mail▶│ Email orchestration             │
│ Thin API route wrappers       │ ─api▶ │ secureAuth.routes.*            │
│ Thin page wrappers            │ ─ui─▶ │ LoginPage, RegisterPage, etc.  │
│ SecureAuthUIProvider          │ ◀cfg─ │ secureAuth.uiConfig            │
└──────────────────────────────┘       └──────────────────────────────┘

The package intentionally does not read process.env directly. That responsibility stays in the application. This makes the package easier to test, easier to reason about, and less coupled to a specific deployment environment.

A typical bootstrap file looks like this:

import "server-only";

import { createSecureAuth } from "@tgoliveira/secure-auth/next";
import { db } from "@/lib/db";
import { emailProvider } from "@/lib/email-provider";

export const secureAuth = createSecureAuth({
  db,
  app: {
    name: "My App",
    slug: "my-app",
    baseUrl: process.env.APP_BASE_URL!,
  },
  auth: {
    secret: process.env.NEXTAUTH_SECRET!,
    twoFactorEncryptionKey: process.env.TWO_FACTOR_SECRET_ENCRYPTION_KEY!,
    afterLoginPath: "/dashboard",
  },
  email: {
    from: "My App <noreply@example.com>",
    provider: emailProvider,
  },
});

Then each route can be mounted with a thin wrapper:

// app/api/auth/register/route.ts
import { secureAuth } from "@/lib/secure-auth";

export const POST = secureAuth.routes.register.POST;

And UI pages can be mounted just as thinly:

// app/login/page.tsx
export { LoginPage as default } from "@tgoliveira/secure-auth/react";

Or customized where needed:

import { LoginPage } from "@tgoliveira/secure-auth/react";

export default function Page() {
  return <LoginPage title="Sign in to My App" />;
}

This is the intended integration style: the app wires, the package implements.


What the package gives you

The package is not only an OAuth wrapper. It is closer to an account-authentication layer for a specific product stack.

It includes support for:

  • email/password registration and login;
  • password hashing;
  • forgot/reset password flows;
  • change password;
  • email verification;
  • OAuth through NextAuth v4;
  • Google, Apple, and Microsoft-style provider configuration;
  • passkey registration and login through WebAuthn;
  • TOTP-based two-factor authentication;
  • encrypted 2FA secrets;
  • backup codes;
  • account sessions;
  • session revocation;
  • optional single active session policy;
  • rate limiting;
  • audit events;
  • safe logging patterns;
  • token hashing at rest;
  • account deletion with re-authentication safeguards;
  • Drizzle schema exports;
  • package migrations;
  • ready-made React pages and components;
  • configurable UI defaults through SecureAuthUIProvider.

This is useful because these features usually need to work together.

For example, password policy is not only a form validation concern. It affects registration, reset password, change password, UI feedback, and server-side enforcement. Session revocation is not only a database operation. It affects account settings, logout behavior, polling, and client state.

Packaging these concerns together makes the system more coherent.


What the package does not do

The package is deliberately scoped to account authentication.

It does not provide:

  • product-specific authorization rules;
  • billing;
  • tenant management;
  • marketing pages;
  • encrypted domain data;
  • vault-style encryption;
  • application-specific user profiles;
  • a built-in SMTP or SendGrid transport;
  • a framework-agnostic API;
  • support for every database or ORM;
  • a production-ready security guarantee.

That last point is important.

@tgoliveira/secure-auth is published and functional, but it is still labeled as an experimental internal release in the 0.1.x line. That means it is worth evaluating, testing, learning from, and possibly using in controlled contexts, but it should not be treated as a stable 1.0 production contract without review.

Authentication packages deserve that honesty.


Why use it instead of only NextAuth?

NextAuth is a strong foundation for OAuth providers, callbacks, session handling, and the familiar Next.js authentication route pattern. Many applications use it successfully.

But NextAuth by itself does not give you a complete account center.

You still have to decide how to implement:

  • your user schema;
  • password registration;
  • email verification;
  • reset password tokens;
  • password policy;
  • passkeys;
  • 2FA;
  • backup codes;
  • session revocation UI;
  • account deletion;
  • audit logging;
  • rate limiting;
  • application-specific account pages.

That is where @tgoliveira/secure-auth positions itself.

It does not replace NextAuth so much as wrap a broader account-authentication system around it for a specific stack.

A fair summary would be:

NextAuth gives you authentication primitives. @tgoliveira/secure-auth tries to give you a reusable account-authentication product layer for Next.js, PostgreSQL, and Drizzle.

If your app only needs “Sign in with Google”, this package may be too much.

If you repeatedly build products that need credentials, OAuth, passkeys, 2FA, sessions, verification, account settings, and security policies, the package starts to make more sense.


When it makes sense to use

This package is most interesting when several of these are true:

  • you are building with Next.js App Router;
  • you use PostgreSQL;
  • you use Drizzle ORM;
  • you want server-side session records;
  • you need credentials and OAuth;
  • you want passkeys or plan to support them;
  • you need 2FA;
  • you care about email verification and password reset flows;
  • you are building more than one product;
  • you prefer an opinionated baseline over custom auth per repository;
  • you are comfortable reviewing an experimental package before production use.

For a solo prototype, it may save time.

For a team building multiple applications, it may save more than time: it may reduce security drift.


When it probably does not make sense

It may not be the right fit if:

  • you are not using Next.js;
  • you are not using PostgreSQL;
  • you use Prisma or another ORM and do not want Drizzle;
  • you need Auth.js v5 today;
  • you need only basic social login;
  • your product requires a deeply custom auth UX from day one;
  • you need a fully stable production contract immediately;
  • your team is not willing to review the security model.

Opinionated packages are valuable when your opinions match theirs. They become friction when they do not.

That is not a flaw. It is the trade-off.


The customization model

The package tries to avoid two extremes.

It is not a black box where every behavior is hidden. It is also not a low-level toolkit where every product must assemble the whole system manually.

Customization happens through a few layers:

createSecureAuth({
  passwordPolicy: {
    minLength: 12,
    enforcement: "warn",
    requireSymbol: true,
    blockCommonPasswords: true,
  },
  sessions: {
    singleActiveSession: true,
    revocationPollIntervalSeconds: 10,
  },
});

For UI, the package exposes provider-level defaults:

import { SecureAuthUIProvider } from "@tgoliveira/secure-auth/react";
import { secureAuth } from "@/lib/secure-auth";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SecureAuthUIProvider config={secureAuth.uiConfig}>
          {children}
        </SecureAuthUIProvider>
      </body>
    </html>
  );
}

And individual page props can override provider defaults:

<LoginPage title="Sign in to My App" />

The practical precedence is:

Page props → SecureAuthUIProvider config → package defaults

Email is also intentionally externalized. The package defines an EmailProvider interface, but the application decides whether it sends through SMTP, a cloud provider, a queue, or a console provider in development.

import type { EmailProvider } from "@tgoliveira/secure-auth/email";

export const consoleEmailProvider: EmailProvider = {
  async send({ to, subject, html, text }) {
    console.info("[email]", { to, subject, text: text ?? html });
  },
};

That is a good boundary: the package owns when auth emails are sent and what they are for; the application owns how they are delivered.


Security posture: useful controls, not magic

The package includes several security-oriented controls:

  • bcrypt password hashing;
  • configurable password policy;
  • TOTP 2FA;
  • encrypted 2FA secrets;
  • backup codes;
  • passkeys through WebAuthn;
  • server-side sessions;
  • session revocation;
  • optional single active session;
  • hashed IP and user-agent metadata;
  • rate limiting;
  • hashed tokens at rest;
  • single-use time-limited tokens;
  • audit events;
  • safe logging and redaction patterns;
  • account deletion safeguards.

These are meaningful building blocks.

But security is not something a package can simply declare into existence. Correct configuration still matters. Secret management still matters. WebAuthn origins and RP IDs must be correct. Email delivery must be trustworthy. OAuth providers need to be configured carefully. The database must be migrated and operated properly. A real production deployment should still go through security review.

That is especially true because the package is still in the 0.1.x-internal phase.

The right way to describe it is not “production-ready authentication solved”. The right way is:

an early, functional, opinionated authentication foundation that centralizes many of the hard parts, while still requiring careful review before production use.

That is a stronger and more credible position.


The interesting engineering idea: public API discipline

One of the most important parts of this package is not a specific feature. It is the boundary discipline.

The package exposes supported entry points such as:

@tgoliveira/secure-auth
@tgoliveira/secure-auth/next
@tgoliveira/secure-auth/react
@tgoliveira/secure-auth/react/client
@tgoliveira/secure-auth/client
@tgoliveira/secure-auth/drizzle/schema
@tgoliveira/secure-auth/email
@tgoliveira/secure-auth/styles.css

Consumers should not deep-import internal source files. They should not depend on internal services directly. They should create the auth system once through createSecureAuth(config) and consume the routes, UI config, schema, and public exports from there.

This matters because packages fail when consumers accidentally couple themselves to internals. Once that happens, every refactor becomes a breaking change in practice, even if it was never meant to be public API.

The presence of a minimal consumer demo is also important. It proves that a new app can integrate through public exports only. That is one of the best ways to prevent a reusable package from secretly depending on its original starter app.


Trade-offs

No architecture choice is free.

Here are the honest trade-offs of this approach:

Trade-off Impact
Opinionated stack Fast path for Next.js + PostgreSQL + Drizzle, but not useful for every team
Package abstraction Less copied code, but more package API to learn
Thin wrappers Integration is simple but still mechanical across routes and pages
NextAuth v4 dependency Works with a stable ecosystem, but Auth.js v5 migration would be future work
Experimental versioning Useful now, but breaking changes are still possible
App-owned env mapping Explicit and testable, but more boilerplate than magic config
App-owned email provider Flexible, but not plug-and-play SMTP
Ready-made UI Great for speed, but highly custom products may override heavily
Security controls Better baseline, but not a substitute for review

For me, the most important trade-off is the first one: this package becomes valuable precisely because it is not trying to support every possible stack.

Generic auth abstractions often become too broad, too configurable, and too hard to reason about. @tgoliveira/secure-auth chooses a narrower road: solve one common stack well enough to reuse.

That is a reasonable bet.


So, does it make sense?

Yes, with the right expectations.

It makes sense if the objective is to stop rebuilding the same account-authentication foundation across multiple Next.js applications. It makes sense if the team values consistency, explicit dependency injection, PostgreSQL-backed auth data, reusable UI, and centralized security improvements.

It makes less sense if the application only needs a tiny login flow, uses a different stack, or needs a fully stable production-grade contract today.

The package is best understood as a reusable foundation, not a final security certification.

It gives developers a strong starting point: routes, schema, services, UI, policies, and integration patterns. It also forces a healthy boundary: the app owns infrastructure and configuration; the package owns the authentication domain.

That separation is the real value.


Final thoughts

Authentication is one of the worst places to accumulate copy-paste architecture.

A starter app can help once. A package can help repeatedly. But only if the package has clear boundaries, public API discipline, a validation consumer, and an honest maturity model.

@tgoliveira/secure-auth is not trying to be everything for everyone. It is an opinionated authentication foundation for teams building with Next.js, PostgreSQL, Drizzle, and TypeScript.

For that audience, it is absolutely worth understanding.

Even if you do not adopt it directly, the architectural lesson is valuable: keep infrastructure in the app, keep reusable domain logic in the package, avoid hidden environment coupling, and validate the public API with a real consumer.

That is a good pattern not only for authentication, but for any internal platform package.

GitHub: github.com/tgoliveira11/next-secure-auth-starter Package: @tgoliveira/secure-auth

Building an Opinionated Authentication Foundation for Next.js and PostgreSQL