# Neon Auth (Better Auth) Integration Standard

> **Scope:** universal
> **Layer:** 2 (on keyword)
> **Keywords:** neon auth, better auth, authentication, nextjs, user management, session, JWT, trusted origins, EdDSA, Ed25519, BouncyCastle, signature validator, jwks, middleware runtime, api prefix, data api, postgrest, bypass backend, direct database access
> **Load When:** neon auth or authentication keywords detected

**Verified against:** Neon Auth — @neondatabase/auth + @neondatabase/auth-ui (Better Auth). Last-verified: 2026-07-13.

> This standard is the reference implementation, not a troubleshooting log. Every section below
> exists because a real end-to-end run (MORPH_GHLBrain, feature `catalogo-produtos-crud`, task T16,
> 2026-07-13) hit the failure mode it documents. See [Known Failure Modes](#known-failure-modes-postmortem)
> for the map from bug → section.

---

Managed authentication powered by Better Auth, with all auth data stored directly in your Neon database (`neon_auth` schema). No external auth providers, no webhooks, no sync delays.

---

## Overview

Neon Auth provides:
- Pre-built UI components (AuthView, UserButton, SignedIn)
- Social auth (Google, GitHub, etc.)
- Session management with HTTP-only cookies
- JWT tokens for Row-Level Security
- Branch-isolated auth data (perfect for preview environments)
- Database as single source of truth

**Primary Use Case:** Next.js 15 + App Router + Neon PostgreSQL

---

## Core Principles

1. **Database-First**: Auth data lives in `neon_auth` schema — no external service to sync
2. **Branch Isolation**: Each Neon branch gets isolated users, sessions, and auth config
3. **Server-First**: Use server-side auth checks with `auth.getSession()`
4. **JWT for RLS**: Tokens contain `sub` (user ID), `email`, `role: "authenticated"` for database
   policies — used only inside the justified Data API exception path, not by default (see
   [Data Access Boundary](#data-access-boundary--backend-first-by-default))
5. **No OIDC discovery**: Neon Auth does **not** expose `/.well-known/openid-configuration` — only the
   raw `/.well-known/jwks.json`. Never assume `options.Authority` alone gives you working discovery on
   the .NET side (see [.NET Backend Integration](#net-backend-integration)).

---

## Data Access Boundary — Backend-First by Default

The Next.js frontend has exactly one inherent reason to talk to Neon directly: Neon Auth's own
client SDK (`authClient`, `useSession`, `auth.getSession()`) — that's Better Auth's session/JWT
mechanism, not a business-data call. Every read or write of application/business data goes through
the .NET backend instead, either as a server-direct fetch or a client-via-proxy call (see
[API Prefix Convention](#api-prefix-convention--server-direct-vs-client-proxy) for the mechanics).

**Rule:** the frontend does not query Neon directly for business data — not via a Neon Data API
(REST-over-Postgres) endpoint, not via a browser-held connection string, not via any other bypass
of the .NET backend. The .NET backend is the sole gateway to business data, for the same reason
`architecture/vertical-slice/vertical-slice.md` makes the backend the sole gateway to the database
in general: handler-level authorization, no business rules duplicated on two sides of the wire, one
place to audit.

**The exception process:** a feature that genuinely needs the frontend to reach Neon directly for
business data (for example, via the Neon MCP `provision_neon_data_api` tool) is an exception, not a
default — treat it exactly like vertical-slice.md's "Stores dedicados (exceção justificada)" clause:
document the justification in the feature's `decisions.md` before writing the code. A tool being
available is not, by itself, justification.

See [Row-Level Security Integration](#row-level-security-integration) for how to constrain a
Data-API-exception session once one exists — those RLS policies are not a signal that the frontend
should have direct access by default.

---

## Installation & Setup

### Install Packages

```bash
# Core auth + UI components are separate packages
npm install @neondatabase/auth@latest @neondatabase/auth-ui @neondatabase/serverless
```

> The UI components (`AuthView`, `UserButton`, `SignedIn`, `SignedOut`,
> `RedirectToSignIn`) live in `@neondatabase/auth-ui`, not in the core
> `@neondatabase/auth` package. The legacy `@stackframe/stack` packages and
> `@neondatabase/neon-auth-next` are deprecated — do not install them.

### Environment Variables

```bash
# .env.local
NEON_AUTH_BASE_URL=https://ep-xxx.neonauth.us-east-2.aws.neon.build/neondb/auth
NEON_AUTH_COOKIE_SECRET=your-secret-at-least-32-characters-long
# Public auth URL consumed by the browser auth client
NEXT_PUBLIC_AUTH_URL=https://ep-xxx.neonauth.us-east-2.aws.neon.build/neondb/auth
```

Generate cookie secret:
```bash
openssl rand -base64 32
```

### Onboarding Checklist — Do This Before Testing Login (mandatory)

Run once per environment (local dev, every preview URL, every new dev machine) — **before** the first
real sign-in attempt, not after debugging a mysterious "session never sticks" bug:

1. **Register every dev/preview origin in `trusted_origins`.** Neon Auth's middleware silently refuses
   to recognize a session on a full-page navigation from an origin it doesn't know about — there is no
   error, the user just bounces back to sign-in as if the session never existed. `localhost:3000` is not
   automatically enough; a second dev port (`localhost:3001`), a new preview deploy URL, or a tunnel URL
   each need to be added explicitly. Use the `configure_neon_auth` tool (Neon MCP) to list current
   `trusted_origins` and add the one you're about to test against:
   ```
   mcp: Neon.configure_neon_auth  →  confirm/add the exact origin (scheme + host + port) you will open in the browser
   ```
   Do this **before** attempting a real login — not as the first debugging step after login silently fails.
2. Confirm `NEON_AUTH_COOKIE_SECRET` is 32+ characters (`openssl rand -base64 32`).
3. Confirm `NEON_AUTH_BASE_URL` / `NEXT_PUBLIC_AUTH_URL` point at the same Neon Auth endpoint you
   configured `trusted_origins` against (branch-specific — see [Branch Isolation](#branch-isolation)).
4. On the .NET side, confirm `NeonAuth:BaseUrl` exists in `appsettings.Development.json` — see
   [appsettings.Development.json (mandatory)](#appsettingsdevelopmentjson-mandatory-public-value).

### SDK Initialization

```typescript
// lib/auth/client.ts
'use client';
import { createAuthClient } from '@neondatabase/auth/next';
// createAuthClient takes the public auth URL as a positional argument
export const authClient = createAuthClient(process.env.NEXT_PUBLIC_AUTH_URL!);
// Re-export the React hooks from the client instance for ergonomic imports
export const { useSession } = authClient;

// lib/auth/server.ts
import { createNeonAuth } from '@neondatabase/auth/next/server';
export const auth = createNeonAuth({
  baseUrl: process.env.NEON_AUTH_BASE_URL!,
  cookies: {
    secret: process.env.NEON_AUTH_COOKIE_SECRET!,
  },
});
```

### Root Layout Provider

Import the Tailwind styles for the UI components in `globals.css`:

```css
@import '@neondatabase/auth-ui/tailwind';
```

```tsx
// app/providers.tsx
'use client';
import { NeonAuthUIProvider } from '@neondatabase/auth-ui';
import { authClient } from '@/lib/auth/client';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <NeonAuthUIProvider authClient={authClient}>
      {children}
    </NeonAuthUIProvider>
  );
}
```

### Auth API Route

```typescript
// app/api/auth/[...path]/route.ts
import { auth } from '@/lib/auth/server';

export const { GET, POST } = auth.handler();
```

---

## Common Patterns

### Sign In / Sign Up Pages

```tsx
// app/auth/sign-in/page.tsx
import { AuthView } from '@neondatabase/auth-ui';

export default function SignInPage() {
  return (
    <div className="flex items-center justify-center min-h-screen">
      <AuthView path="sign-in" />
    </div>
  );
}

// app/auth/sign-up/page.tsx
import { AuthView } from '@neondatabase/auth-ui';

export default function SignUpPage() {
  return (
    <div className="flex items-center justify-center min-h-screen">
      <AuthView path="sign-up" />
    </div>
  );
}
```

### Server-Side Auth Check

```typescript
// app/dashboard/page.tsx
import { auth } from '@/lib/auth/server';
import { redirect } from 'next/navigation';

export const dynamic = 'force-dynamic';

export default async function DashboardPage() {
  const { data: session } = await auth.getSession();

  if (!session?.user) {
    redirect('/auth/sign-in');
  }

  return <div>Dashboard for {session.user.name || session.user.email}</div>;
}
```

### Client Component with User Data

```tsx
// components/UserProfile.tsx
'use client';
import { useSession } from '@/lib/auth/client';

export function UserProfile() {
  const { data } = useSession();
  const user = data?.user;

  if (!user) return <div>Not signed in</div>;

  return (
    <div>
      <p>Welcome, {user.name || user.email}!</p>
    </div>
  );
}
```

### Route Protection (Middleware)

```typescript
// middleware.ts
import { auth } from '@/lib/auth/server';

// MANDATORY for any middleware.ts that touches Neon Auth. Next.js middleware runs on the Edge
// runtime by default, and the network call the Neon Auth SDK makes to mint the session-data cookie
// times out inside the Edge sandbox on `next dev` — the request silently bounces back to sign-in
// even with a fully valid session, with no error surfaced. Force the Node.js runtime instead.
export const runtime = 'nodejs';

export default auth.middleware({
  loginUrl: '/auth/sign-in',
});

export const config = {
  matcher: [
    '/dashboard/:path*',
    '/settings/:path*',
    '/((?!_next/static|_next/image|favicon.ico|auth).*)',
  ],
};
```

### Component-Level Protection

```tsx
import { SignedIn, RedirectToSignIn } from '@neondatabase/auth-ui';

export default function ProtectedPage() {
  return (
    <>
      <SignedIn>
        <div>Protected content</div>
      </SignedIn>
      <RedirectToSignIn />
    </>
  );
}
```

---

## OAuth (Social Login)

```typescript
// Trigger OAuth sign-in
await authClient.signIn.social({
  provider: 'google',
  callbackURL: 'http://localhost:3000/auth/callback',
});
```

Configure OAuth providers in the Neon Console under Auth > Configuration.

---

## API Route Protection

```typescript
// app/api/protected/route.ts
import { auth } from '@/lib/auth/server';

export const dynamic = 'force-dynamic';

export async function GET() {
  const { data: session } = await auth.getSession();

  if (!session?.user) {
    return new Response('Unauthorized', { status: 401 });
  }

  return Response.json({ userId: session.user.id });
}
```

---

## API Prefix Convention — Server-Direct vs Client-Proxy

> Assumes the [Data Access Boundary](#data-access-boundary--backend-first-by-default) rule: all
> business data routes through the backend by default. This section covers only the *mechanics* of
> doing so from each render context.

A Next.js app talking to a Neon-Auth-protected .NET backend fetches from **two different places**
depending on where the code runs:

- **Server Components / Route Handlers** call the .NET backend directly (server-to-server, no browser
  involved) — the backend's own route prefix applies (e.g. the backend exposes `catalog/items`, no
  `/api` prefix at all if that's how the VSA endpoints are mapped).
- **Client Components** cannot call the .NET backend's origin directly without CORS/cookie complications,
  so they call a **same-origin Next.js proxy route** (e.g. `/api/catalog/items`) which forwards the
  request (and the session cookie) to the .NET backend.

These are **two distinct prefixes with two distinct purposes**. Collapsing them into a single shared
constant (`API_PREFIX = '/api'` used for both) silently breaks one of the two call sites the moment the
backend's real prefix and the proxy's public prefix don't happen to match character-for-character — and
because this is a runtime routing mismatch (404, or the proxy calling itself), it **never shows up at
build time**, only when a human clicks the page.

```typescript
// lib/api/prefixes.ts
// BACKEND_API_PREFIX: used ONLY by Server Components / Route Handlers calling the .NET backend directly.
export const BACKEND_API_PREFIX = process.env.API_URL; // e.g. https://api.example.com — backend's own routes have NO /api prefix

// PROXY_API_PREFIX: used ONLY by Client Components calling the same-origin Next.js proxy.
export const PROXY_API_PREFIX = '/api/proxy'; // matches app/api/proxy/[...path]/route.ts
```

```typescript
// app/(dashboard)/catalog/page.tsx — Server Component, direct backend call
import { BACKEND_API_PREFIX } from '@/lib/api/prefixes';

async function getItems() {
  // cross-reference: PROXY_API_PREFIX below MUST resolve to the same backend route
  const res = await fetch(`${BACKEND_API_PREFIX}/catalog/items`, { next: { revalidate: 60 } });
  return res.json();
}
```

```typescript
// features/catalog/hooks/use-items.ts — Client Component, via same-origin proxy
import { PROXY_API_PREFIX } from '@/lib/api/prefixes';

export function useItems() {
  return useQuery({
    queryKey: ['catalog', 'items'],
    // cross-reference: BACKEND_API_PREFIX above MUST resolve to the same backend route
    queryFn: async () => (await fetch(`${PROXY_API_PREFIX}/catalog/items`)).json(),
  });
}
```

**Rule:** never reuse `BACKEND_API_PREFIX` for a proxy call, and never reuse `PROXY_API_PREFIX` for a
direct server call. If a review needs to check this mechanically: grep for both constants and confirm
neither name appears on the "wrong" side (Server Component files should only import
`BACKEND_API_PREFIX`; Client Component / hook files should only import `PROXY_API_PREFIX`).

---

## .NET Backend Integration

Neon Auth issues standard JWTs, **signed with EdDSA (Ed25519, JWK `kty: "OKP"`)**. The .NET API must
validate these tokens itself — there is no external auth SDK and, critically, **no OIDC discovery
document to lean on**.

> **`options.Authority` alone does not work here.** Neon Auth does not serve
> `/.well-known/openid-configuration` — only the raw `/.well-known/jwks.json`. Setting `Authority` and
> trusting the framework's normal metadata-refresh machinery either does nothing useful or wastes retries
> against a URL that never returns valid OIDC metadata. Fetch and cache the JWKS yourself (below).

> **`Microsoft.IdentityModel.Tokens` cannot verify these signatures out of the box.** It has no support
> for OKP (EdDSA/Ed25519) keys — `JsonWebKeySet.GetSigningKeys()` silently drops any OKP key, so
> `IssuerSigningKeyResolver` never gets a usable key, and every legitimate token is rejected with a bare
> `401` and no useful error message. .NET has no built-in (BCL) Ed25519 primitive either. The fix below
> uses `BouncyCastle.Cryptography` (pure C#, no native `libsodium` dependency — safe on Linux containers)
> and a manual `TokenValidationParameters.SignatureValidator`.

### NuGet Packages

```xml
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
<!-- Pure C# Ed25519 — .NET's BCL has no EdDSA primitive. Pin >= 2.3.1: CVE-2024-30172 (infinite loop
     on a crafted signature) was fixed in 2.3.1; use the current stable release. -->
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
```

### DI Registration Checklist (do this before marking any auth task done)

Minimal API endpoints infer a lambda parameter's binding source from its type: known simple types come
from the route/query, `ClaimsPrincipal`/`CancellationToken` are special-cased, and any other complex
type is bound to the **request body** *unless* it's registered in the DI container, in which case it's
resolved as a service instead. If you inject a custom service (a tenant resolver, a current-user
accessor, anything beyond the built-ins) into an endpoint lambda **without registering it**, ASP.NET
Core silently treats it as a second `[FromBody]` parameter — and the app crashes at startup with
*"Action ... has more than one parameter that was specified or inferred as bound from request body"*,
taking down every endpoint in that assembly, not just the one you forgot.

Before marking any task that adds or touches an endpoint as done:
1. List every parameter type in the endpoint lambda that isn't `TRequest`, `CancellationToken`,
   `ClaimsPrincipal`, or a route/query primitive.
2. For each one, confirm a matching `AddScoped<T>()` / `AddSingleton<T>()` / `AddTransient<T>()` exists
   in `Program.cs` (or via assembly-scanning registration).
3. If it's missing, the fix is the registration — not a `[FromServices]` attribute band-aid.

### JWT Bearer Configuration

```csharp
// Infrastructure/Auth/NeonAuthOptions.cs
namespace {ProjectName}.Infrastructure.Auth;

public sealed class NeonAuthOptions
{
    public const string SectionName = "NeonAuth";

    /// <summary>Neon Auth Base URL, e.g. https://ep-xxx.neonauth.us-east-2.aws.neon.build/neondb/auth</summary>
    public string BaseUrl { get; set; } = string.Empty;
}
```

```csharp
// Infrastructure/Auth/NeonAuthJwksProvider.cs
namespace {ProjectName}.Infrastructure.Auth;

using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

/// <summary>
/// Fetches and caches Neon Auth's raw JWKS (/.well-known/jwks.json — there is no OIDC discovery
/// document). Cached for 15 minutes; a forced refresh happens once when an unknown `kid` is seen,
/// to survive key rotation without waiting out the TTL.
/// </summary>
public sealed class NeonAuthJwksProvider(HttpClient httpClient, IOptions<NeonAuthOptions> options)
{
    private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(15);
    private readonly SemaphoreSlim _refreshLock = new(1, 1);
    private JsonWebKeySet? _cached;
    private DateTimeOffset _cachedAt;

    public async Task<JsonWebKey?> GetKeyAsync(string kid, CancellationToken cancellationToken)
    {
        var keySet = await GetKeySetAsync(forceRefresh: false, cancellationToken);
        var key = keySet.Keys.FirstOrDefault(k => k.Kid == kid);
        if (key is not null) return key;

        // Unknown kid — could be key rotation. Force a single refresh before giving up.
        keySet = await GetKeySetAsync(forceRefresh: true, cancellationToken);
        return keySet.Keys.FirstOrDefault(k => k.Kid == kid);
    }

    private async Task<JsonWebKeySet> GetKeySetAsync(bool forceRefresh, CancellationToken cancellationToken)
    {
        if (!forceRefresh && _cached is not null && DateTimeOffset.UtcNow - _cachedAt < CacheTtl)
            return _cached;

        await _refreshLock.WaitAsync(cancellationToken);
        try
        {
            if (!forceRefresh && _cached is not null && DateTimeOffset.UtcNow - _cachedAt < CacheTtl)
                return _cached;

            var jwksUri = $"{options.Value.BaseUrl.TrimEnd('/')}/.well-known/jwks.json";
            var json = await httpClient.GetStringAsync(jwksUri, cancellationToken);
            _cached = new JsonWebKeySet(json);
            _cachedAt = DateTimeOffset.UtcNow;
            return _cached;
        }
        finally
        {
            _refreshLock.Release();
        }
    }
}
```

```csharp
// Infrastructure/Auth/NeonAuthServiceExtensions.cs
namespace {ProjectName}.Infrastructure.Auth;

using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;

public static class NeonAuthServiceExtensions
{
    public static IServiceCollection AddNeonAuthentication(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.Configure<NeonAuthOptions>(configuration.GetSection(NeonAuthOptions.SectionName));
        services.AddHttpClient<NeonAuthJwksProvider>();

        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                // Do NOT set options.Authority — Neon Auth serves no /.well-known/openid-configuration,
                // so Authority-driven metadata refresh has nothing valid to fetch. ValidIssuer is set
                // explicitly below instead.
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidIssuer = configuration["NeonAuth:BaseUrl"],
                    ValidateAudience = false,
                    ValidateLifetime = true,
                    NameClaimType = "sub",
                    // Trust in the signing key is established entirely inside SignatureValidator
                    // below (fetched from our own cached JWKS) — this flag doesn't gate that path.
                    ValidateIssuerSigningKey = false,
                };
            });

        // A second Configure<TDep> pass with DI available — AddJwtBearer's own setup has already run,
        // so this only *adds* the SignatureValidator to the TokenValidationParameters built above.
        services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
            .Configure<NeonAuthJwksProvider>((options, jwks) =>
            {
                options.TokenValidationParameters.SignatureValidator = (token, _) =>
                {
                    var jwt = new JsonWebToken(token);

                    // Pin the algorithm — refuse anything that isn't EdDSA to block alg-substitution attacks.
                    if (!string.Equals(jwt.Alg, "EdDSA", StringComparison.Ordinal))
                        throw new SecurityTokenInvalidSignatureException(
                            $"Unsupported alg '{jwt.Alg}' — Neon Auth tokens must be EdDSA.");

                    var kid = jwt.Kid;
                    if (string.IsNullOrEmpty(kid))
                        throw new SecurityTokenInvalidSignatureException("Token is missing 'kid'.");

                    // Blocking call: JWKS is cached in-memory (see NeonAuthJwksProvider), so this is
                    // I/O-free on the hot path. SignatureValidator has no async overload in this
                    // library version — only a cold-start / key-rotation miss ever awaits real I/O here.
                    var jwk = jwks.GetKeyAsync(kid, CancellationToken.None).GetAwaiter().GetResult()
                        ?? throw new SecurityTokenInvalidSignatureException($"No JWKS key found for kid '{kid}'.");

                    if (jwk.Kty != "OKP" || jwk.Crv != "Ed25519")
                        throw new SecurityTokenInvalidSignatureException("JWKS key is not an Ed25519 (OKP) key.");

                    var parts = token.Split('.');
                    if (parts.Length != 3)
                        throw new SecurityTokenInvalidSignatureException("Malformed JWT — expected 3 segments.");

                    var signingInput = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
                    var signature = Base64UrlEncoder.DecodeBytes(parts[2]);
                    var publicKey = new Ed25519PublicKeyParameters(Base64UrlEncoder.DecodeBytes(jwk.X), 0);

                    var verifier = new Ed25519Signer();
                    verifier.Init(forSigning: false, publicKey);
                    verifier.BlockUpdate(signingInput, 0, signingInput.Length);

                    if (!verifier.VerifySignature(signature))
                        throw new SecurityTokenInvalidSignatureException("Ed25519 signature verification failed.");

                    // MUST return Microsoft.IdentityModel.JsonWebTokens.JsonWebToken — ASP.NET Core 8+
                    // uses JsonWebTokenHandler internally. Returning a
                    // System.IdentityModel.Tokens.Jwt.JwtSecurityToken here fails with IDX10506
                    // ("SignatureValidator returned a token of an unexpected type").
                    return jwt;
                };
            });

        services.AddAuthorization();

        return services;
    }
}
```

### Protected Endpoints

```csharp
app.MapGet("/api/profile", (ClaimsPrincipal user) =>
{
    var userId = user.FindFirstValue("sub");
    if (userId is null) return Results.Unauthorized();
    return Results.Ok(new { UserId = userId });
}).RequireAuthorization();
```

### appsettings.Development.json (mandatory, public value)

```json
{
  "NeonAuth": {
    "BaseUrl": "https://ep-xxx.neonauth.us-east-2.aws.neon.build/neondb/auth"
  }
}
```

This is **not optional** and **not a secret** — it's the Neon Auth endpoint URL, safe to commit.
Without it, `NeonAuthOptions.BaseUrl` resolves to an empty string, the JWKS fetch fails, and every
request is rejected the same way a broken signature validator would fail: a bare `401` with nothing
in the logs pointing at the real cause. Add it to `appsettings.Development.json` specifically (not only
`appsettings.json`) when scaffolding the backend — it's easy to add the entry to one and forget the
other, and the app only fails in the environment missing it.

### Program.cs Usage

```csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddNeonAuthentication(builder.Configuration);

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers().RequireAuthorization();

app.Run();
```

---

## Row-Level Security Integration

> Defense-in-depth on a JWT-scoped Postgres session — the Data API exception above, not a directive
> to call Neon from the browser for business data by default. See
> [Data Access Boundary](#data-access-boundary--backend-first-by-default).

When a Data API endpoint is provisioned (the justified exception above), Neon Auth JWT tokens are
automatically validated by it. The `auth.user_id()` function (via `pg_session_jwt` extension)
exposes the authenticated user ID from the JWT `sub` claim to any RLS policy on that session.

```sql
-- Enable RLS
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;

-- Users can only access their own rows
CREATE POLICY "Users can view own documents"
  ON public.documents FOR SELECT TO authenticated
  USING (user_id = auth.user_id());

CREATE POLICY "Users can insert own documents"
  ON public.documents FOR INSERT TO authenticated
  WITH CHECK (user_id = auth.user_id());
```

---

## Database Schema

Neon Auth automatically manages the `neon_auth` schema with these tables:
- `neon_auth.user` — User profiles
- `neon_auth.session` — Active sessions
- `neon_auth.account` — Auth credentials (password hashes, OAuth tokens)

You can query these tables directly for user data — no webhook sync needed:
```sql
SELECT id, email, name FROM neon_auth.user WHERE id = auth.user_id();
```

---

## Branch Isolation

Each Neon branch gets its own isolated auth data. This means:
- Preview branches have separate users and sessions
- Feature branches don't share auth state with production
- Perfect for PR preview environments

---

## Best Practices

1. **Always use `force-dynamic`** on pages/routes that call `auth.getSession()`
2. **Cookie secret must be 32+ characters** — generate with `openssl rand -base64 32`
3. **Use `auth.user_id()` for RLS** — never extract JWT claims manually in SQL
4. **Configure OAuth in Neon Console** — not in code
5. **Use `SignedIn` / `RedirectToSignIn`** for client-side protection
6. **Use middleware** for broad route protection patterns — and force `runtime = 'nodejs'` (Edge times
   out minting the session-data cookie)
7. **Register every dev/preview origin in `trusted_origins`** before testing login, not after it fails
8. **Never share a route-prefix constant** between server-direct backend calls and client-side proxy
   calls — use two named constants (`BACKEND_API_PREFIX` / `PROXY_API_PREFIX`)
9. **Verify DI registration for every service injected into an endpoint lambda** — an unregistered
   service silently becomes a phantom `[FromBody]` parameter and crashes the app at startup
10. **On .NET, never rely on `options.Authority` alone** — Neon Auth has no OIDC discovery document;
    fetch and cache the JWKS yourself and verify EdDSA signatures manually (BouncyCastle)
11. **Route business data through the .NET backend by default** — the frontend's only inherent
    reason to talk to Neon directly is its own auth/session SDK; a direct Data API call needs a
    `decisions.md` ADR (see [Data Access Boundary](#data-access-boundary--backend-first-by-default))

---

## Known Failure Modes (Postmortem)

Real bugs hit during the `catalogo-produtos-crud` end-to-end run (MORPH_GHLBrain, task T16,
2026-07-13). Each one now has a permanent fix in this standard — this table exists so a review can
scan it in one pass instead of rediscovering these by trial and error.

| # | Symptom | Root cause | Fixed in |
|---|---------|------------|----------|
| 1 | App crashes at startup on a specific endpoint | Custom service injected into an endpoint lambda without DI registration — Minimal API infers it as a second `[FromBody]` parameter | [DI Registration Checklist](#di-registration-checklist-do-this-before-marking-any-auth-task-done) |
| 2 | A route works from the server but 404s from the client (or vice versa) | Single shared prefix constant reused for both the direct backend call and the same-origin proxy call | [API Prefix Convention](#api-prefix-convention--server-direct-vs-client-proxy) |
| 3 | Session never sticks after a full-page navigation, no error shown | New dev/preview origin not registered in Neon Auth `trusted_origins` | [Onboarding Checklist](#onboarding-checklist--do-this-before-testing-login-mandatory) |
| 4 | Valid session still bounces back to sign-in on `next dev` | `middleware.ts` runs on Edge runtime by default; the session-data cookie mint call times out there | [Route Protection (Middleware)](#route-protection-middleware) |
| 5 | Every legitimate request gets a bare `401`, no useful log | `Microsoft.IdentityModel.Tokens` can't verify EdDSA/OKP signatures — `IssuerSigningKeyResolver` never finds a usable key | [.NET Backend Integration](#net-backend-integration) |
| 6 | Same bare-`401` symptom as #5, different cause | `NeonAuth:BaseUrl` missing from `appsettings.Development.json` | [appsettings.Development.json](#appsettingsdevelopmentjson-mandatory-public-value) |

---

## References

- [Neon Auth Documentation](https://neon.com/docs/auth)
- [Neon Auth Authentication Flow](https://neon.com/docs/auth/authentication-flow)
- [Migration from Legacy Auth](https://neon.com/docs/auth/migrate/from-legacy-auth)
- [RFC 8037 — EdDSA for JOSE](https://www.rfc-editor.org/rfc/rfc8037) — `alg: "EdDSA"`, `kty: "OKP"`, `crv: "Ed25519"`
- [BouncyCastle.Cryptography (NuGet)](https://www.nuget.org/packages/BouncyCastle.Cryptography)

---

*MORPH-SPEC by Polymorphism Tech*
