Neon Better Auth + Flask Integration Guide

A battle-tested guide for integrating Neon Auth (powered by Better Auth) with a Flask application. This document captures every pitfall encountered during a production deployment and how to solve them.

Table of Contents

  1. Architecture Overview
  2. Prerequisites
  3. Configuration
  4. Client-Side Integration
  5. Server-Side Integration
  6. Authentication Flows
  7. Multi-Tenant Post-Auth Routing
  8. Pitfalls & Lessons Learned
  9. Debugging Checklist
  10. File Reference

Architecture Overview

Browser (login page)
   |
   |  1. User enters credentials or clicks "Google Sign In"
   v
Neon Auth Server (Better Auth REST API)
   |
   |  2. Returns session token (opaque, NOT always a JWT)
   v
Browser (neon-auth.js)
   |
   |  3. POSTs token + user data to Flask backend
   v
Flask Backend (/auth/neon-callback)
   |
   |  4. Validates token via multiple strategies
   |  5. Establishes Flask session
   v
Dashboard / Tenant Selection / Organization Registration

Key architectural insight: Neon Auth runs on its own domain. It sets session cookies on its domain, which means your Flask server cannot read those cookies directly. All tokens must be explicitly passed from the client to Flask via request bodies.


Prerequisites

  • Python 3.12+ with Flask 3.x
  • PostgreSQL database (Neon) with the neon_auth schema enabled
  • A Neon Auth project configured in the Neon Console
  • PyJWT and cryptography packages for JWKS verification
pip install flask flask-sqlalchemy pyjwt requests cryptography

Neon Console Setup

  1. Enable Neon Auth in your Neon project
  2. Configure authentication methods (email/password, Google OAuth, etc.)
  3. Add your production domain to Trusted Origins (e.g., https://yourdomain.com)
  4. Note your Neon Auth URL (e.g., https://auth.xxxx.neon.tech)

CRITICAL: If you skip step 3, all browser-to-Neon-Auth requests will fail with Invalid origin. This is the most commonly missed step.


Configuration

Environment Variables

NEON_AUTH_URL=https://auth.xxxx.neon.tech    # Your Neon Auth endpoint
DATABASE_URL=postgresql://...                 # Must be on the same Neon project

Flask Config

# config/base.py
class BaseConfig:
    NEON_AUTH_URL = os.environ.get('NEON_AUTH_URL', '')
    NEON_AUTH_JWKS_URL = os.environ.get('NEON_AUTH_JWKS_URL', '')  # Optional override

HTML Meta Tag

Your base layout must expose the Neon Auth URL to client-side JavaScript:

<!-- base/layout.html -->
<head>
    <meta name="neon-auth-url" content="{{ config.get('NEON_AUTH_URL') }}">
</head>

Client-Side Integration

Script Loading

Load your auth client script once in the base layout, with a cache-busting parameter:

<!-- base/layout.html -->
<script src="{{ url_for('static', filename='js/neon-auth.js') }}?v={{ cache_version }}"></script>

Pitfall #1: Duplicate script loading. If you load neon-auth.js in both your base layout AND in a page-specific {% block extra_js %}, you will get Identifier 'NeonAuthClient' has already been declared. Only load it once in the base layout.

Pitfall #2: Browser caching. During active development, always use a cache-busting query parameter (?v=timestamp). Without it, browsers may serve stale JavaScript for hours even after deploying fixes.

NeonAuthClient Class

The client-side class wraps the Neon Auth REST API:

class NeonAuthClient {
    constructor(authUrl) {
        this.authUrl = authUrl.replace(/\/$/, '');
    }
 
    async signInWithEmailPassword(email, password) {
        const response = await fetch(`${this.authUrl}/sign-in/email`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email, password }),
            credentials: 'include'  // REQUIRED for cross-origin cookies
        });
        // ...
    }
}

The credentials: 'include' option is mandatory on every fetch to Neon Auth. Without it, the browser will not send/receive cookies from the Neon Auth domain.

Passing Tokens to Flask

After authenticating with Neon Auth, you must explicitly pass the token to your Flask backend. Do NOT rely on cookies:

// After successful Neon Auth sign-in
const signInData = await response.json();
 
// Extract token - check multiple locations
const token = signInData.token
    || (signInData.session && signInData.session.token)
    || null;
 
// POST to Flask backend with token AND user data
const callbackResponse = await fetch('/auth/neon-callback', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        token: token,
        user: signInData.user || null  // Fallback user data
    }),
    credentials: 'include'
});

Pitfall #3: Token location varies. Better Auth may return the token at data.token, data.session.token, or neither. Always check multiple locations. If no token is found, call the get-session endpoint as a fallback.

Handling Google OAuth Redirects

After Google OAuth, Neon Auth redirects back to your app with a neon_auth_session_verifier query parameter. You need to detect this on every page (not just the callback URL):

// Run on every page load
function checkSessionVerifier() {
    const params = new URLSearchParams(window.location.search);
    const verifier = params.get('neon_auth_session_verifier');
 
    if (!verifier) return;
 
    // Fetch session from Neon Auth (client-side cookies are accessible)
    const sessionData = await fetch(`${this.authUrl}/get-session`, {
        credentials: 'include'
    }).then(r => r.json());
 
    // Forward to Flask
    await fetch('/auth/neon-callback', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            token: sessionData.session?.token,
            user: sessionData.user
        }),
        credentials: 'include'
    });
}

Pitfall #4: OAuth redirect URL. Neon Auth may redirect to your root URL (/?neon_auth_session_verifier=...) rather than a dedicated callback path. Your session verifier detection must run on every page, not just /auth/callback.


Server-Side Integration

Token Validation Strategies

Better Auth can issue opaque session tokens (short random strings), not just JWTs. Your backend must handle both:

class NeonAuthService:
    def get_user_from_token(self, token: str) -> Optional[User]:
        """Try JWT first, fall back to opaque token validation"""
        # Strategy 1: JWT verification via JWKS
        payload = self.verify_token(token)
 
        if not payload:
            # Strategy 2: Opaque token validation
            payload = self.validate_session_token(token)
            if not payload:
                return None
            # CRITICAL: Rollback any dirty DB state from failed lookups
            try:
                db.session.rollback()
            except Exception:
                pass
 
        return User.authenticate_with_jwt(payload)

Opaque Token Validation (4 Strategies)

When JWT verification fails, try these strategies in order:

def validate_session_token(self, token: str) -> Optional[dict]:
    # Strategy 1: Pass token as cookie to Neon Auth get-session API
    response = requests.get(
        f"{auth_url}/get-session",
        headers={'Cookie': f'better-auth.session_token={token}'},
        timeout=10
    )
 
    # Strategy 2: Pass token as Bearer header
    response = requests.get(
        f"{auth_url}/get-session",
        headers={'Authorization': f'Bearer {token}'},
        timeout=10
    )
 
    # Strategy 3: Direct DB lookup (plain token)
    result = db.session.execute(
        text("SELECT ... FROM neon_auth.session WHERE token = :token"),
        {'token': token}
    )
 
    # Strategy 4: DB lookup with SHA-256 hashed token
    hashed = hashlib.sha256(token.encode('utf-8')).hexdigest()
    result = db.session.execute(
        text("SELECT ... FROM neon_auth.session WHERE token = :token"),
        {'token': hashed}
    )

Pitfall #5: Better Auth stores hashed tokens. The neon_auth.session table stores SHA-256 hashed session tokens, not plain text. A plain token lookup will always return no results. You must hash the token before querying the database.

Handling neon_auth Schema Column Names

Better Auth’s database schema uses different column naming conventions depending on the ORM adapter (Drizzle uses camelCase, Prisma uses snake_case). Your queries must try multiple conventions:

# camelCase (Drizzle ORM default)
"""SELECT u.id, u.email, u.name, u."emailVerified"
   FROM neon_auth.session s
   JOIN neon_auth."user" u ON s."userId" = u.id
   WHERE s.token = :token AND s."expiresAt" > NOW()"""
 
# snake_case (Prisma)
"""SELECT u.id, u.email, u.name, u.email_verified
   FROM neon_auth.session s
   JOIN neon_auth."user" u ON s.user_id = u.id
   WHERE s.token = :token AND s.expires_at > NOW()"""
 
# all lowercase (PostgreSQL unquoted default)
"""SELECT u.id, u.email, u.name, u.emailverified
   FROM neon_auth.session s
   JOIN neon_auth."user" u ON s.userid = u.id
   WHERE s.token = :token AND s.expiresat > NOW()"""

Pitfall #6: PostgreSQL transaction poisoning. When a SQL query fails (e.g., column expiresAt doesn’t exist), PostgreSQL puts the connection into InFailedSqlTransaction state. ALL subsequent queries will fail until you call db.session.rollback(). You MUST rollback after each failed query attempt.

for query_sql in [camelcase_query, snake_case_query, lowercase_query]:
    try:
        result = db.session.execute(text(query_sql), {'token': token})
        if result:
            return parse_result(result)
    except Exception:
        # CRITICAL: Rollback to prevent transaction poisoning
        try:
            db.session.rollback()
        except Exception:
            pass
        continue

UUID vs String Type Mismatch

The neon_auth.user.id column is a PostgreSQL UUID type. If your application’s User model stores neon_auth_user_id as VARCHAR, you must cast the value to str before comparing:

@classmethod
def find_by_neon_auth_id(cls, neon_auth_user_id: str):
    # CRITICAL: Cast to str to prevent "operator does not exist: character varying = uuid"
    neon_auth_user_id = str(neon_auth_user_id)
    return db.session.execute(
        db.select(cls).where(cls.neon_auth_user_id == neon_auth_user_id)
    ).scalar_one_or_none()

Pitfall #7: UUID type mismatch. The neon_auth schema returns UUID objects from PostgreSQL. If you pass these directly to a query comparing against a VARCHAR column, PostgreSQL throws operator does not exist: character varying = uuid. Always cast to str().

Parsing Neon Auth API Responses

The get-session endpoint may return null instead of {} for invalid sessions:

def _parse_session_response(self, response):
    if not response.ok:
        return None
    try:
        data = response.json()
    except Exception:
        return None
 
    # CRITICAL: data can be None (JSON "null"), not just an empty dict
    if not data or not isinstance(data, dict):
        return None
 
    user_data = data.get('user')
    if not user_data:
        return None
 
    # Always cast sub to str (may be UUID object)
    user_id = user_data.get('id')
    return {
        'sub': str(user_id) if user_id else None,
        'email': user_data.get('email'),
        'name': user_data.get('name'),
        'email_verified': user_data.get('emailVerified',
                            user_data.get('email_verified', False)),
    }

Pitfall #8: Null JSON body. Neon Auth returns HTTP 200 with body null for some invalid session requests. response.json() returns Python None, and None.get('user') raises AttributeError. Always check for None before accessing dict methods.

Flask Callback Endpoint

The main callback endpoint should accept tokens from the request body and include a client-data fallback:

@auth_bp.route('/neon-callback', methods=['POST'])
def neon_callback():
    body = request.get_json(silent=True) or {}
 
    # 1. Get token from body (preferred) or cookie (fallback)
    session_token = body.get('token') or request.cookies.get('better-auth.session_token')
 
    if not session_token:
        return jsonify({'error': 'No session token'}), 401
 
    # 2. Validate token and get user
    auth_service = AuthService()
    user = auth_service.authenticate_jwt(session_token)
 
    # 3. Fallback: use client-provided user data
    if not user:
        client_user = body.get('user')
        if client_user and client_user.get('id') and client_user.get('email'):
            try:
                db.session.rollback()  # Clean up any dirty transaction
            except Exception:
                pass
            user = User.authenticate_with_jwt({
                'sub': client_user['id'],
                'email': client_user['email'],
                'name': client_user.get('name', ''),
                'email_verified': client_user.get('emailVerified', False),
            })
 
    if user:
        auth_service.establish_session(user)
        redirect_url = auth_service.resolve_post_auth_redirect(user.user_id)
        return jsonify({'success': True, 'redirect': redirect_url})
 
    return jsonify({'error': 'Invalid session'}), 401

Proxying Neon Auth Endpoints

To avoid CORS issues, proxy email verification and password reset through Flask:

@auth_bp.route('/verify-email', methods=['POST'])
def verify_email():
    """Proxy email OTP verification to Neon Auth"""
    data = request.get_json()
    response = requests.post(
        f"{neon_auth_url}/email-otp/verify-email",
        json={'email': data['email'], 'otp': data['otp']},
        timeout=10
    )
    return jsonify(response.json()), response.status_code
 
 
@auth_bp.route('/forgot-password', methods=['POST'])
def forgot_password():
    """Proxy forgot-password to Neon Auth"""
    data = request.get_json(silent=True) or {}
    response = requests.post(
        f"{neon_auth_url}/forget-password/email",
        json={'email': data['email']},
        timeout=10
    )
    return jsonify(response.json()), response.status_code

Authentication Flows

Email/Password Sign-In

User                Browser (JS)           Neon Auth            Flask
 |                      |                      |                  |
 |  Enter credentials   |                      |                  |
 |--------------------->|                      |                  |
 |                      |  POST /sign-in/email |                  |
 |                      |--------------------->|                  |
 |                      |  { token, user }     |                  |
 |                      |<---------------------|                  |
 |                      |                      |                  |
 |                      |  POST /auth/neon-callback               |
 |                      |  { token, user }     |                  |
 |                      |---------------------------------------->|
 |                      |                      |  validate token  |
 |                      |                      |  establish session|
 |                      |  { redirect: "/dashboard" }             |
 |                      |<----------------------------------------|
 |  Redirect            |                      |                  |
 |<---------------------|                      |                  |

Google OAuth

User                Browser (JS)           Neon Auth            Flask
 |                      |                      |                  |
 |  Click "Google"      |                      |                  |
 |--------------------->|                      |                  |
 |                      | POST /sign-in/social |                  |
 |                      |--------------------->|                  |
 |                      |  { url: google... }  |                  |
 |                      |<---------------------|                  |
 |  Redirect to Google  |                      |                  |
 |<---------------------|                      |                  |
 |                      |                      |                  |
 |  (Google auth)       |                      |                  |
 |  Redirect back       |                      |                  |
 |----> /?neon_auth_session_verifier=xxx       |                  |
 |                      |                      |                  |
 |       (or)           |                      |                  |
 |----> /auth/callback?neon_auth_session_verifier=xxx             |
 |                      |                      |                  |
 |                      |  GET /get-session    |                  |
 |                      |--------------------->|                  |
 |                      |  { session, user }   |                  |
 |                      |<---------------------|                  |
 |                      |                      |                  |
 |                      |  POST /auth/neon-callback               |
 |                      |  { token, user }                        |
 |                      |---------------------------------------->|
 |                      |  { redirect: "/dashboard" }             |
 |                      |<----------------------------------------|
 |  Redirect            |                      |                  |
 |<---------------------|                      |                  |

Email Sign-Up with OTP Verification

User                Browser (JS)           Neon Auth            Flask
 |                      |                      |                  |
 |  Submit signup form  |                      |                  |
 |--------------------->|                      |                  |
 |                      | POST /sign-up/email  |                  |
 |                      |--------------------->|                  |
 |                      |  (sends OTP email)   |                  |
 |                      |<---------------------|                  |
 |                      |                      |                  |
 |  Show OTP overlay    |                      |                  |
 |<---------------------|                      |                  |
 |                      |                      |                  |
 |  Enter 6-digit OTP   |                      |                  |
 |--------------------->|                      |                  |
 |                      | POST /auth/verify-email (proxied)       |
 |                      |---------------------------------------->|
 |                      |              POST /email-otp/verify-email|
 |                      |                      |<-----------------|
 |                      |                      |  { success }     |
 |                      |                      |----------------->|
 |                      |  { success }         |                  |
 |                      |<----------------------------------------|
 |                      |                      |                  |
 |                      |  GET /get-session    |                  |
 |                      |--------------------->|                  |
 |                      |  { session, user }   |                  |
 |                      |<---------------------|                  |
 |                      |                      |                  |
 |                      |  POST /auth/neon-callback               |
 |                      |  { token, user }                        |
 |                      |---------------------------------------->|
 |                      |  { redirect }                           |
 |                      |<----------------------------------------|

Multi-Tenant Post-Auth Routing

After authentication, route users based on their tenant memberships:

def resolve_post_auth_redirect(self, user_id: int) -> str:
    memberships = self._get_user_memberships(user_id)
 
    if not memberships:
        # New user, no organization yet
        return url_for('auth.no_organization')
 
    if len(memberships) == 1:
        # Auto-select single tenant
        self.establish_tenant_session(user_id, memberships[0]['tenant_id'])
        return url_for('main.dashboard')
 
    # Multiple tenants - try default, then show picker
    default = next((m for m in memberships if m['is_default']), None)
    if default:
        self.establish_tenant_session(user_id, default['tenant_id'])
        return url_for('main.dashboard')
 
    return url_for('auth.select_tenant')

Required pages:

  • /auth/no-organization - Shown when user has no tenant memberships
  • /auth/register-organization - Form to create a new tenant
  • /auth/select-tenant - Picker when user belongs to multiple tenants

Pitfalls & Lessons Learned

Summary Table

#PitfallSymptomSolution
1Duplicate script loadingIdentifier already declaredLoad neon-auth.js only once in base layout
2Browser cachingOld JS served after deployAdd ?v=timestamp cache-busting param
3Token location variesToken undefinedCheck data.token, data.session.token, then getSession()
4OAuth redirect to root URLHomepage shown with verifier paramDetect neon_auth_session_verifier on every page
5Hashed tokens in DBDB lookup returns no resultsHash token with SHA-256 before querying
6PostgreSQL transaction poisoningInFailedSqlTransaction on all queriesdb.session.rollback() after each failed SQL attempt
7UUID vs VARCHAR mismatchoperator does not exist: varchar = uuidCast neon_auth_user_id to str() before queries
8Null JSON body from APIAttributeError: NoneType has no attribute getCheck if not data or not isinstance(data, dict)
9Cross-origin cookies invisibleFlask can’t read Neon Auth cookiesPass tokens in request body, not cookies
10Chart.js ESM vs UMDCannot use import statement outside a moduleUse chart.umd.min.js (not chart.min.js)
11Neon Auth trusted originsInvalid origin errorAdd your domain to Neon Console trusted origins
12Missing templates500 Internal Server ErrorCreate all templates referenced in route handlers
13Column naming conventionsSQL errors on neon_auth schema queriesTry camelCase, snake_case, and lowercase variants

Detailed Lessons

This is the single most important thing to understand: Neon Auth’s session cookies live on Neon Auth’s domain, not yours. Your Flask backend on yourdomain.com will never see a cookie set by auth.xxxx.neon.tech.

The solution is a two-step dance:

  1. Client-side JavaScript can access Neon Auth cookies (via credentials: 'include')
  2. Client reads the session/token from Neon Auth, then POSTs it to your Flask backend

This is why you need the neon-auth.js client library on every page.

PostgreSQL Transaction Poisoning (The Silent Killer)

This bug is particularly insidious because it causes cascading failures that appear unrelated to the root cause. When you try multiple SQL queries against the neon_auth schema and one fails (e.g., wrong column name), PostgreSQL enters a failed transaction state. Every subsequent query on that connection will fail with:

psycopg2.errors.InFailedSqlTransaction: current transaction is aborted,
commands ignored until end of transaction block

The fix: Call db.session.rollback() after ANY potentially-failing database query, especially when trying multiple schema variants. Also rollback after validate_session_token() returns, before performing any User model queries.

Better Auth Token Format

Better Auth does NOT always issue JWTs. It often issues opaque session tokens - short random strings (32 characters) that are not self-verifiable. Your backend must support both:

  1. JWT path: Fetch JWKS from /.well-known/jwks.json, verify signature
  2. Opaque path: Call Neon Auth’s get-session API or query the DB directly

When querying the database directly, remember that Better Auth stores tokens as SHA-256 hashes:

import hashlib
hashed_token = hashlib.sha256(token.encode('utf-8')).hexdigest()

Debugging Checklist

When authentication fails, check these in order:

  1. Browser Console: Any JavaScript errors? Is window.neonAuth defined?
  2. Network Tab: What does the Neon Auth sign-in response look like? Is there a token field?
  3. /auth/neon-callback response: Check the debug info in the JSON response
  4. Server logs: Look for the validation strategy results:
    • jwt_valid: true/false
    • opaque_valid: true/false
    • DB plain lookup SUCCESS/failed
    • DB hashed lookup SUCCESS/failed
  5. Transaction state: Look for InFailedSqlTransaction - indicates a missing rollback
  6. Type errors: Look for character varying = uuid - indicates missing str() cast
  7. Neon Console: Is your domain in the trusted origins list?
  8. Cache: Force-refresh the page (Ctrl+Shift+R) to bypass cached JavaScript

Adding a Debug Panel

During development, a floating debug panel is invaluable. Add one to your auth client:

class NeonAuthClient {
    _addDebugLog(message) {
        const panel = document.getElementById('neonAuthDebugPanel');
        if (!panel) this._createDebugPanel();
        const entry = document.createElement('div');
        entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
        panel.querySelector('.logs').appendChild(entry);
    }
}

Include a “Copy All Logs” button so users can easily share diagnostics. Remove the debug panel before production release.


File Reference

FilePurpose
app/static/js/neon-auth.jsClient-side Neon Auth SDK wrapper
app/services/auth_service.pyServer-side token validation and session management
app/views/auth.pyFlask routes for all auth endpoints
app/models/user.pyUser model with authenticate_with_jwt()
app/templates/auth/login.htmlSign-in/sign-up page with OTP verification
app/templates/auth/oauth_completing.htmlOAuth bridge page for cross-origin token passing
app/templates/auth/no_organization.htmlShown when user has no tenant memberships
app/templates/auth/register_organization.htmlOrganization creation form
app/templates/auth/select_tenant.htmlMulti-tenant picker
app/templates/auth/verify_email.htmlStandalone email verification page
app/templates/base/layout.htmlBase template with Neon Auth meta tag and script
config/base.pyFlask configuration with NEON_AUTH_URL

Version History

  • 2026-02-21: Initial guide based on production deployment of RepairOS
  • Covers Neon Auth (Better Auth) integration with Flask + multi-tenant architecture
  • Tested with: Flask 3.1.3, SQLAlchemy 2.0.36, PyJWT 2.10.1, PostgreSQL (Neon), Python 3.12