@tgoliveira/secure-auth
v0.13.0
Published
Opinionated Next.js + Drizzle + PostgreSQL authentication package (experimental internal release)
Maintainers
Readme
@tgoliveira/secure-auth
Version: see package.json (experimental — not production-ready)
Opinionated authentication package for Next.js App Router, TypeScript, Drizzle ORM, and PostgreSQL.
Composition root (read this first)
Consumers integrate exclusively through:
import { createSecureAuth } from "@tgoliveira/secure-auth/next";
export const secureAuth = createSecureAuth(config);
// secureAuth.routes.register.POST
// secureAuth.uiConfig → SecureAuthUIProvider| Do | Don't |
| --- | --- |
| Create one secureAuth instance in app bootstrap | Import @tgoliveira/secure-auth/server (removed) |
| Wire routes: secureAuth.routes.register.POST | Call createRoutes or createAuthServices |
| Pass secureAuth.uiConfig to SecureAuthUIProvider | Call internal runtime helpers |
| Map env vars in your app | Expect the package to read process.env |
Onboarding docs: configuration-reference.md · consumer-quick-start.md · minimal-consumer-example.md · package-api.md
In-repo validation app: apps/consumer-demo — minimal consumer with public exports only (see consumer-demo-validation.md).
UI provider
Page copy, paths, password policy defaults, and configured OAuth provider IDs come from config — not global state or client-side provider discovery.
// app/layout.tsx
import { SecureAuthUIProvider } from "@tgoliveira/secure-auth/react";
import { secureAuth } from "@/lib/secure-auth";
export default function RootLayout({ children }) {
return (
<SecureAuthUIProvider config={secureAuth.uiConfig}>
{children}
</SecureAuthUIProvider>
);
}Configure via createSecureAuth({ ui: { paths, messages, cssVariables, passwordStrength }, passwordPolicy: { minLength } }). Package pages use useSecureAuthUi() internally when wrapped in SecureAuthUIProvider config={secureAuth.uiConfig}. Default minimum password length is 12; map AUTH_PASSWORD_MIN_LENGTH in your app env into passwordPolicy.minLength to override (the package never reads env directly). The resolved policy is available as secureAuth.passwordPolicy and secureAuth.uiConfig.passwordPolicy.
secureAuth.uiConfig.oauthProviderIds contains only effective provider IDs. LoginPage,
RegisterPage, and SocialSignIn render that final list synchronously without requesting
/api/auth/providers. Missing UI config fails closed and renders no OAuth controls. For standalone
composition, pass providerIds directly to SocialSignIn. Provider secrets and client IDs remain
server-only.
Password strength and validation feedback render above password fields by default. Set ui.passwordStrength.position to "below" to restore legacy placement. The feedback region is stable from first render (neutral requirements before typing; strength updates in place without focus loss). See customization.md.
Install (consumer app)
npm install @tgoliveira/secure-auth@latest \
next@^16.2.11 react@^19 react-dom@^19 next-auth@^4.24.15 drizzle-orm@^0.45.2See publishing-npm-automation.md for automated npm releases and consumer-quick-start.md for install.
The package writes new password digests with server-only Argon2id. Keep
@node-rs/argon2 in the consuming app's Next.js serverExternalPackages alongside postgres,
bcryptjs, and @simplewebauthn/server; the full snippet is in the consumer quick start.
Existing bcrypt accounts need no migration script or user action: a successful password login
replaces the legacy digest atomically while preserving the password-change timestamp.
Supported public entry points
| Import | Purpose |
| --- | --- |
| @tgoliveira/secure-auth/next | createSecureAuth(config) — composition root |
| @tgoliveira/secure-auth | Types, manifest-derived SECURE_AUTH_PACKAGE_VERSION, authSchema, safeLogger |
| @tgoliveira/secure-auth/react | UI primitives, pages, SecureAuthUIProvider, SecureAuthUIPublicConfig |
| @tgoliveira/secure-auth/react/client | Client-only UI, passkey sign-in, default sign-out |
| @tgoliveira/secure-auth/client | Browser API client, passkey helpers |
| @tgoliveira/secure-auth/client/password-policy | Password policy helpers |
| @tgoliveira/secure-auth/drizzle/schema | Auth Drizzle schema |
| @tgoliveira/secure-auth/email | EmailProvider types |
| @tgoliveira/secure-auth/styles.css | Tailwind v4 source registration (CSS import) |
Unsupported: @tgoliveira/secure-auth/server, createRoutes, createAuthServices, deep src/** imports.
Full reference: package-api.md.
Quick start
import { createSecureAuth } from "@tgoliveira/secure-auth/next";
import type { EmailProvider } from "@tgoliveira/secure-auth/email";
import { db } from "@/lib/db";
const emailProvider: EmailProvider = {
async send({ to, subject, html, text }) {
// app-owned delivery (SMTP, console, etc.)
},
};
export const secureAuth = createSecureAuth({
db,
app: { name: "My App", slug: "my-app", baseUrl: process.env.APP_BASE_URL! },
auth: {
afterLoginPath: "/dashboard",
redirectAuthenticatedFromGuestPages: true,
authenticatedRedirectPath: "/dashboard",
afterLogoutPath: "/",
requireEmailVerificationBeforeSignIn: false,
nextAuthSecret: process.env.NEXTAUTH_SECRET!,
twoFactorEncryptionKey: process.env.TWO_FACTOR_SECRET_ENCRYPTION_KEY!,
},
email: { from: "My App <[email protected]>", provider: emailProvider },
webauthn: {
rpId: process.env.WEBAUTHN_RP_ID!,
rpName: "My App",
origin: process.env.WEBAUTHN_ORIGIN!,
originAliasPolicy: process.env.NODE_ENV === "production" ? "none" : "apex-www",
},
ui: {
paths: { login: "/login", register: "/register" },
messages: { loginTitle: "Sign in to My App" },
},
});webauthn.originAliasPolicy defaults to "apex-www" for compatibility, including the local
localhost/127.0.0.1 pair. Set it to "none" when production redirects to one canonical host; only
the primary origin and exact webauthn.origins entries are then accepted by assertion verification.
For login and package-owned exact-credential assertions, secure-auth presents internal before
hybrid and adds advisory WebAuthn Level 3 local-first hints when both transports are available.
Hybrid remains an allowed fallback, user verification remains required, and browsers without hints
support continue from the same credential allow-list. No authenticator attachment is forced.
Consumers that compose an independent browser-only capability may set the server-only
webauthn.getLoginAuthenticationExtensions({ userId, credentialIds }) callback. It can add only
bounded JSON-safe WebAuthn extension inputs after the account allow-list is resolved; it cannot
replace the challenge, RP ID, user-verification policy, or credentials. Pair it with
PasskeyLoginHooks.prepareOptions and the typed onFullyAuthenticated result. See
passkey-credential-interoperability.md.
Deterministic cross-device vault unlock should use the separate, opt-in portable broker grant module. It runs a dedicated UV-required assertion after full account authentication, signs a short-lived ES256 grant, and verifies/consumes broker completion receipts without importing vault-core or receiving PUK/PRF/UVK material. See portable-vault-grants.md.
Route handlers
Thin App Router wrappers in the consuming app:
// src/app/api/auth/register/route.ts
import { secureAuth } from "@/lib/secure-auth";
export const POST = secureAuth.routes.register.POST;All handlers: secureAuth.routes.*. Route map: package-api.md.
Tailwind CSS (v4)
@import "tailwindcss";
@import "@tgoliveira/secure-auth/styles.css";Define :root CSS variables for theme tokens (see starter globals.css).
EmailProvider
Account email flows through your injected provider only. Transport lives in the app; templates use config.app.name and config.app.baseUrl.
Reference: apps/dev-harness/src/modules/email/core/ + apps/dev-harness/src/lib/secure-auth.ts.
Configuration
The package does not read runtime environment variables. Map secrets at the app boundary in createSecureAuth(config).
Canonical reference: configuration-reference.md — every env variable, TypeScript config path, defaults, and parsing rules.
Services receive config and db via constructor injection — there is no global runtime state.
See architecture.md.
Optional session policy — multiple concurrent sessions is the default. Enable single active session per user via env or config:
AUTH_SINGLE_ACTIVE_SESSION=truecreateSecureAuth({
sessions: { singleActiveSession: true },
});See customization.md and security.md.
When singleActiveSession is enabled, other sessions are revoked only after final login completion (including post-TOTP verification for passkey and credentials logins).
Passkeys and two-factor authentication
Passkey sign-in is a primary authentication method. When TOTP 2FA is enabled on the account, passkey verification creates a pending login challenge (same httpOnly cookie and /login/2fa flow as email/password). The session is finalized only after valid TOTP verification — passkeys do not bypass app-level 2FA.
Capability boundaries: account security settings list passkeys with signInEnabled /
vaultUnlockEnabled metadata. Only pure account sign-in passkeys are removable from account
settings; vault-only and dual-capability credentials are protected at the API and UI. Existing
vault-only credentials can be explicitly enabled for sign-in with an exact UV-required proof; they
are never silently upgraded.
Optional browser-only preparation and verified callbacks let a consumer compose another capability
without making secure-auth depend on that package. Secure-auth strips PRF client-extension results
before serialization and rejects them at every account verification route. See
consumer-passkey-capability-boundaries.md,
passkey-registration-capability-boundary-audit.md,
and passkey-credential-interoperability.md.
Portable broker adoption and migration 0005_nasty_slipstream.sql are documented in
portable-vault-grants.md.
CAPTCHA (Cloudflare Turnstile)
Optional bot protection for credentials registration and login. Disabled by default.
createSecureAuth({
captcha: {
enabled: true,
provider: "turnstile",
siteKey: "...",
secretKey: "...",
pages: { register: true, login: true },
},
});Server-side Siteverify validation is mandatory when enabled. Only siteKey and page flags are exposed via uiConfig; secretKey stays server-only. OAuth and passkey flows are not CAPTCHA-protected in this release.
Magic link
Optional passwordless email login. Disabled by default.
createSecureAuth({
auth: {
magicLink: { enabled: true },
},
});When enabled, the login page shows a Sign in with email link option. The user receives a single-use link valid for 15 minutes. If the account has 2FA enabled, magic link verification creates a pending 2FA challenge (same flow as credentials login). Rate limit: 3 requests per email per 10 minutes. Request responses are anti-enumeration safe.
Wire routes: magicLinkRequest.POST → /api/auth/magic-link/request, magicLinkVerify.POST → /api/auth/magic-link/verify. Email links point to LoginMagicLinkPage (/login/magic-link by default), which POSTs the token to the verify API.
Generic password components
Reusable password policy UI for non-auth flows (vault password, encryption password, etc.).
import {
PasswordStrengthField,
PasswordSetupFields,
} from "@tgoliveira/secure-auth/react/client";Validation helpers live in @tgoliveira/secure-auth/client/password-policy. The package never reads env — map consumer env to a policy object and pass it as props.
See generic-password-components.md.
OAuth providers
Optional social sign-in via NextAuth v4. Map credentials in your app — the package never reads process.env:
createSecureAuth({
oauth: {
google: { clientId, clientSecret },
apple: { clientId, clientSecret },
github: { clientId, clientSecret },
microsoft: { clientId, clientSecret, tenantId },
},
});Supported provider ids: google, apple, github, azure-ad (Microsoft). Buttons render on the
first client frame only when the matching effective config is present in
secureAuth.uiConfig.oauthProviderIds. GitHub callback:
{APP_BASE_URL}/api/auth/callback/github. See
configuration-reference.md.
Database
- Schema:
@tgoliveira/secure-auth/drizzle/schema - Migrations: shipped in package
migrations/folder - App owns
DATABASE_URL; package owns auth tables
Shared-credential deployments must apply 0004_outgoing_william_stryker.sql before deploying the
corresponding package. Vault-only to sign-in promotion is hidden by default and requires the
explicit page/component promotion prop in addition to mounting the route.
See migrations.md and consumer-quick-start.md.
Versioning
| Range | Meaning |
| --- | --- |
| 0.1.x | Experimental internal |
| 0.2.x | DB contract may break; API stabilization |
| 1.0.0 | Production-ready contract |
Security
See security.md for passwords, tokens, HIBP breach detection, and security notifications. Not production-ready at 0.2.x.
Before releases: npm run audit:security from the monorepo root. Details: dependency-audit.md.
License
MIT © 2026 Thiago Oliveira. See LICENSE.
