# Punch Drunk Auth - Integration Guide for Replit Agents

This document provides complete instructions for integrating any application with Punch Drunk Auth, a centralized authentication system. It is designed to be consumed by AI agents (such as Replit Agent) building applications that need user authentication.

## Overview

Punch Drunk Auth handles all user authentication so your app doesn't have to. It supports:
- Email/password registration and login
- Google OAuth sign-in
- TOTP-based two-factor authentication (MFA)
- Password reset and email verification
- User profile management

Your app redirects users to Punch Drunk Auth to sign in. After authentication, users are redirected back to your app with a secure JWT token containing their identity.

## Auth Base URL

```
https://auth.punch-drunk.com
```

## Required Environment Variables

Your app needs these values from the Punch Drunk Auth admin dashboard (Connected Apps page):

| Variable | Required For | Description |
|---|---|---|
| `PUNCH_DRUNK_API_KEY` | Login flow, token validation | Identifies your app. Safe to use in redirect URLs. Starts with `ak_`. |
| `PUNCH_DRUNK_SECRET_KEY` | Embed widget only | Only needed if using the embeddable account widget. **Never expose in frontend code.** Starts with `sk_`. |
| `PUNCH_DRUNK_REDIRECT_URI` | Login flow | Your app's callback URL, e.g. `https://yourapp.replit.app/auth/callback`. Must exactly match what is registered in the dashboard. |
| `SESSION_SECRET` | Session security | A random secret string for signing session cookies. Generate a long random value (32+ characters). |
| `DATABASE_URL` | Session storage | PostgreSQL connection string. Already available on Replit if you have a database provisioned. Required for persistent sessions. |

For basic login integration, you need `PUNCH_DRUNK_API_KEY`, `PUNCH_DRUNK_REDIRECT_URI`, `SESSION_SECRET`, and `DATABASE_URL`. The secret key is only required if you want to embed the account management widget.

## Integration Flow

```
1. User clicks "Login" in your app
2. Your app redirects to: https://auth.punch-drunk.com/authorize?client_id=PUNCH_DRUNK_API_KEY&redirect_uri=PUNCH_DRUNK_REDIRECT_URI
3. User authenticates on Punch Drunk Auth (handles registration, login, MFA, Google OAuth)
4. Punch Drunk Auth redirects back to your callback URL with: ?token=JWT_TOKEN&state=CSRF_STATE
5. Your backend validates the token via POST to /api/apps/validate-token
6. Your app creates a local session with the verified user info
```

## Step-by-Step Implementation

**Before implementing these routes**, make sure your Express app has `app.set("trust proxy", 1)`, a PostgreSQL-backed session store, and correct cookie settings. See the "Complete Express.js Example" and "Replit Deployment Gotchas" sections below for the full setup. Skipping this causes a double-login bug where users authenticate but your app doesn't recognize them.

### Step 1: Login Route

Create a route that redirects users to Punch Drunk Auth:

```javascript
const PUNCH_DRUNK_AUTH_URL = "https://auth.punch-drunk.com";

app.get("/login", (req, res) => {
  // Skip the auth flow if user already has a session
  if (req.session.userId) return res.redirect("/");

  const params = new URLSearchParams({
    client_id: process.env.PUNCH_DRUNK_API_KEY,
    redirect_uri: process.env.PUNCH_DRUNK_REDIRECT_URI,
  });
  res.redirect(`${PUNCH_DRUNK_AUTH_URL}/authorize?${params}`);
});
```

### Step 2: Callback Route

Handle the redirect back from Punch Drunk Auth:

```javascript
app.get("/auth/callback", async (req, res) => {
  const { token } = req.query;
  if (!token) return res.redirect("/?auth_error=missing_token");

  try {
    // Validate the token with Punch Drunk Auth
    const response = await fetch(
      `${PUNCH_DRUNK_AUTH_URL}/api/apps/validate-token`,
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          token,
          apiKey: process.env.PUNCH_DRUNK_API_KEY,
        }),
      }
    );

    const data = await response.json();
    if (!data.valid) return res.redirect("/?auth_error=invalid_token");

    // Regenerate session to get a fresh session ID (prevents session fixation)
    req.session.regenerate((err) => {
      if (err) {
        console.error("Session regenerate error:", err);
        return res.redirect("/?auth_error=server_error");
      }

      // Set user data on the fresh session
      req.session.userId = data.user.userId;
      req.session.email = data.user.email;
      req.session.username = data.user.username;
      req.session.displayName = data.user.displayName;
      req.session.avatar = data.user.avatar;

      // CRITICAL: Save session before redirecting to avoid race condition
      req.session.save((err) => {
        if (err) {
          console.error("Session save error:", err);
          return res.redirect("/?auth_error=server_error");
        }
        res.redirect("/dashboard"); // or wherever your app's main page is
      });
    });
  } catch (err) {
    console.error("Auth callback error:", err);
    res.redirect("/?auth_error=server_error");
  }
});
```

### Step 3: Logout Route

Log users out of both your app and Punch Drunk Auth:

```javascript
app.get("/logout", (req, res) => {
  req.session.destroy(() => {
    // IMPORTANT: Redirect to your app's root URL, NOT the /auth/callback URL.
    // Using PUNCH_DRUNK_REDIRECT_URI here would send users to /auth/callback
    // after logout, which returns a "Missing token" error.
    const appBase = process.env.PUNCH_DRUNK_REDIRECT_URI
      ? new URL(process.env.PUNCH_DRUNK_REDIRECT_URI).origin
      : "https://yourapp.replit.app";
    const redirect = encodeURIComponent(appBase);
    res.redirect(`${PUNCH_DRUNK_AUTH_URL}/logout?redirect_to=${redirect}`);
  });
});
```

### Step 4: Auth Middleware

Protect routes that require authentication:

```javascript
function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.redirect("/login");
  }
  next();
}

// For API routes
function requireAuthAPI(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ message: "Not authenticated" });
  }
  next();
}

app.get("/dashboard", requireAuth, (req, res) => {
  // User is authenticated
});
```

## API Reference

### POST /api/apps/validate-token

Validates a JWT token received from the OAuth callback.

**Request:**
```json
{
  "token": "the_jwt_token_from_callback",
  "apiKey": "your_PUNCH_DRUNK_API_KEY"
}
```

**Success Response (200):**
```json
{
  "valid": true,
  "user": {
    "userId": 1,
    "id": 1,
    "email": "user@example.com",
    "username": "johndoe",
    "name": "John Doe",
    "displayName": "John Doe",
    "picture": "https://example.com/avatar.jpg",
    "avatar": "https://example.com/avatar.jpg"
  }
}
```

**Error Response (401):**
```json
{
  "valid": false,
  "message": "Invalid or expired token"
}
```

### GET /authorize

Initiates the OAuth login flow. Redirect users here.

**Query Parameters:**
| Parameter | Required | Description |
|---|---|---|
| `client_id` | Yes | Your app's `PUNCH_DRUNK_API_KEY` |
| `redirect_uri` | Yes | Must exactly match the registered callback URL |

### GET /logout

Logs the user out of Punch Drunk Auth and redirects.

**Query Parameters:**
| Parameter | Required | Description |
|---|---|---|
| `redirect_to` | No | URL to redirect to after logout. Must match origin of a registered app. |

### POST /api/embed/token

Request a short-lived token for embedding the account management widget.

**Request:**
```json
{
  "apiKey": "your_PUNCH_DRUNK_API_KEY",
  "secretKey": "your_PUNCH_DRUNK_SECRET_KEY",
  "userId": 123
}
```

**Response:**
```json
{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "expiresIn": 3600
}
```

## Embeddable Account Widget

Let users manage their profile and security settings inside your app via iframe:

```html
<iframe
  src="https://auth.punch-drunk.com/embed?token=EMBED_TOKEN"
  style="width: 100%; border: none; min-height: 500px;"
  allow="clipboard-write"
></iframe>
```

**Optional URL parameters:**
- `section=profile|security` - Default tab to show
- `tabs=false` - Show only one section without tab navigation

**PostMessage Events (from widget to parent):**
- `pd:ready` - Widget loaded
- `pd:resize` - Resize iframe (includes `height` property)
- `pd:profile-updated` - User updated profile
- `pd:password-changed` - User changed password

**Send theme to widget:**
```javascript
iframe.contentWindow.postMessage(
  { type: "pd:set-theme", theme: "dark" }, // or "light"
  "https://auth.punch-drunk.com"
);
```

## User Object Fields

When you validate a token, the `user` object contains:

| Field | Type | Description |
|---|---|---|
| `userId` | number | Unique user ID (same as `id`) |
| `id` | number | Unique user ID |
| `email` | string | User's email address |
| `username` | string | User's username |
| `name` | string | Display name (falls back to username) |
| `displayName` | string | User's display name |
| `picture` | string/null | Avatar URL (same as `avatar`) |
| `avatar` | string/null | Avatar URL |

## Database Schema Suggestion

If your app needs to store user references locally, create a users table that maps to Punch Drunk Auth user IDs:

```sql
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  punch_drunk_user_id INTEGER UNIQUE NOT NULL,
  email TEXT NOT NULL,
  username TEXT NOT NULL,
  display_name TEXT,
  avatar_url TEXT,
  created_at TIMESTAMP DEFAULT NOW(),
  last_login_at TIMESTAMP
);
```

On each login callback, upsert the user:

```javascript
// After validating token, upsert user in your local database
const localUser = await db.query(
  `INSERT INTO users (punch_drunk_user_id, email, username, display_name, avatar_url, last_login_at)
   VALUES ($1, $2, $3, $4, $5, NOW())
   ON CONFLICT (punch_drunk_user_id) DO UPDATE SET
     email = $2, username = $3, display_name = $4, avatar_url = $5, last_login_at = NOW()
   RETURNING *`,
  [data.user.userId, data.user.email, data.user.username, data.user.displayName, data.user.avatar]
);
```

## Security Notes

- **API Key** (`PUNCH_DRUNK_API_KEY`): Identifies your app. Can be included in redirect URLs. Used for login redirects and token validation.
- **Secret Key** (`PUNCH_DRUNK_SECRET_KEY`): Only required for the embed widget (`/api/embed/token`). Not needed for basic login/token validation. Never expose in client-side code.
- **Token Validation**: The `/api/apps/validate-token` endpoint only requires the `apiKey` (not the secret key). Punch Drunk Auth uses the stored secret internally to verify the JWT.
- **Token Expiry**: JWT tokens expire after 1 hour. Always validate on your backend.
- **Redirect URI**: Must exactly match what's registered. No wildcards or partial matches.
- **MFA**: Handled automatically by Punch Drunk Auth. Your app needs no special MFA handling.
- **CSRF**: A `state` parameter is included in callbacks for verification.

## Complete Express.js Example

```javascript
import express from "express";
import session from "express-session";
import connectPgSimple from "connect-pg-simple";
import pg from "pg";

const app = express();
const PUNCH_DRUNK_AUTH_URL = "https://auth.punch-drunk.com";

// CRITICAL: Trust the reverse proxy so secure cookies work on Replit
app.set("trust proxy", 1);

// PostgreSQL session store (MemoryStore loses sessions on restart)
const PgSession = connectPgSimple(session);
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

app.use(session({
  name: "myapp.sid", // CRITICAL: Use a unique name — NOT the default "connect.sid" (see Gotcha #10)
  store: new PgSession({
    pool,
    tableName: "session",
    createTableIfMissing: false, // See "Session Table Setup" below
  }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",   // MUST be "lax" — "strict" blocks cookies on auth redirects
    maxAge: 24 * 60 * 60 * 1000, // 24 hours
  },
}));

// Login - redirect to Punch Drunk Auth
app.get("/login", (req, res) => {
  // Skip the auth flow if user already has a session
  if (req.session.user) return res.redirect("/");

  const params = new URLSearchParams({
    client_id: process.env.PUNCH_DRUNK_API_KEY,
    redirect_uri: process.env.PUNCH_DRUNK_REDIRECT_URI,
  });
  res.redirect(`${PUNCH_DRUNK_AUTH_URL}/authorize?${params}`);
});

// Callback - validate token and create session
app.get("/auth/callback", async (req, res) => {
  const { token } = req.query;
  if (!token) return res.redirect("/?auth_error=missing_token");

  try {
    const response = await fetch(`${PUNCH_DRUNK_AUTH_URL}/api/apps/validate-token`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ token, apiKey: process.env.PUNCH_DRUNK_API_KEY }),
    });

    const data = await response.json();
    if (!data.valid) return res.redirect("/?auth_error=invalid_token");

    // Regenerate session to get a fresh session ID (prevents session fixation)
    req.session.regenerate((err) => {
      if (err) {
        console.error("Session regenerate error:", err);
        return res.redirect("/?auth_error=server_error");
      }

      req.session.user = data.user;
      // CRITICAL: Always save session before redirecting to avoid race conditions
      req.session.save((err) => {
        if (err) {
          console.error("Session save error:", err);
          return res.redirect("/?auth_error=server_error");
        }
        res.redirect("/");
      });
    });
  } catch (err) {
    console.error("Auth callback error:", err);
    res.redirect("/?auth_error=server_error");
  }
});

// Logout - destroy session and redirect to Punch Drunk Auth logout
app.get("/logout", (req, res) => {
  req.session.destroy(() => {
    // Use the app root URL, NOT the /auth/callback URL
    const appBase = process.env.PUNCH_DRUNK_REDIRECT_URI
      ? new URL(process.env.PUNCH_DRUNK_REDIRECT_URI).origin
      : "https://yourapp.replit.app";
    const redirect = encodeURIComponent(appBase);
    res.redirect(`${PUNCH_DRUNK_AUTH_URL}/logout?redirect_to=${redirect}`);
  });
});

// Auth middleware
function requireAuth(req, res, next) {
  if (!req.session.user) return res.redirect("/login");
  next();
}

// Get current user API
app.get("/api/me", (req, res) => {
  if (!req.session.user) return res.status(401).json({ message: "Not authenticated" });
  res.json(req.session.user);
});

// Protected route example
app.get("/", requireAuth, (req, res) => {
  res.send(`Hello ${req.session.user.displayName}!`);
});

app.listen(5000, "0.0.0.0", () => console.log("Server running on port 5000"));
```

### Session Table Setup

Before your app can store sessions, create the `session` table in your PostgreSQL database. Run this SQL once:

```sql
CREATE TABLE IF NOT EXISTS "session" (
  "sid" varchar NOT NULL COLLATE "default",
  "sess" json NOT NULL,
  "expire" timestamp(6) NOT NULL,
  CONSTRAINT "session_pkey" PRIMARY KEY ("sid")
);
CREATE INDEX IF NOT EXISTS "IDX_session_expire" ON "session" ("expire");
```

**Important:** Do NOT use `createTableIfMissing: true` in the `connect-pg-simple` config. That option tries to read a `table.sql` file from disk, which does not exist in Replit's production build folder (`dist/`). This causes `ENOENT` errors and a complete login loop. Always create the table manually via SQL and set `createTableIfMissing: false`.

## Recommended: Auth Code Exchange Pattern

The basic integration pattern sets session data during the `/auth/callback` redirect. This can fail in some hosting environments (including Replit) where the `Set-Cookie` header is not reliably sent on 3xx redirect responses — especially when using `saveUninitialized: false` with `express-session`.

The auth code exchange pattern avoids this entirely by **never setting session data during a redirect**. Instead, the callback generates a short-lived one-time code, and the client exchanges it for a session via a normal POST request.

### How it works

1. `/auth/callback` validates the PD Auth token
2. Instead of setting session fields, it generates a random auth code
3. The code + user data are stored in a server-side Map with a 60-second expiry
4. The callback redirects to `/?auth_code=CODE`
5. Client-side JavaScript reads the code from the URL
6. Client calls `POST /api/auth/exchange` with `{ code }`
7. The exchange endpoint sets session fields and returns user data as JSON
8. The `Set-Cookie` header is sent on a normal 200 response — not a redirect

### Server implementation

```javascript
import { randomBytes } from "crypto";

const PUNCH_DRUNK_AUTH_URL = "https://auth.punch-drunk.com";

// In-memory store for one-time auth codes
const pendingAuthTokens = new Map();

// Clean up expired codes every 60 seconds
setInterval(() => {
  const now = Date.now();
  for (const [key, val] of pendingAuthTokens) {
    if (val.expiresAt < now) pendingAuthTokens.delete(key);
  }
}, 60000);

// Callback — validate token, generate auth code, redirect
app.get("/auth/callback", async (req, res) => {
  const { token } = req.query;
  if (!token) return res.redirect("/?auth_error=missing_token");

  try {
    const response = await fetch(`${PUNCH_DRUNK_AUTH_URL}/api/apps/validate-token`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ token, apiKey: process.env.PUNCH_DRUNK_API_KEY }),
    });

    const data = await response.json();
    if (!data.valid) return res.redirect("/?auth_error=invalid_token");

    // Generate a one-time auth code instead of setting session here
    const authCode = randomBytes(32).toString("hex");
    pendingAuthTokens.set(authCode, {
      user: data.user,
      expiresAt: Date.now() + 60000, // 60 second expiry
    });

    return res.redirect(`/?auth_code=${authCode}`);
  } catch (error) {
    console.error("Auth callback error:", error);
    return res.redirect("/?auth_error=server_error");
  }
});

// Exchange endpoint — trade auth code for a session
app.post("/api/auth/exchange", express.json(), (req, res) => {
  const { code } = req.body;
  if (!code || typeof code !== "string") {
    return res.status(400).json({ error: "Missing auth code" });
  }

  const pending = pendingAuthTokens.get(code);
  if (!pending || pending.expiresAt < Date.now()) {
    pendingAuthTokens.delete(code);
    return res.status(401).json({ error: "Invalid or expired auth code" });
  }

  pendingAuthTokens.delete(code);

  const user = pending.user;

  // Regenerate session for a fresh session ID (prevents session fixation)
  req.session.regenerate((err) => {
    if (err) {
      console.error("Session regenerate error:", err);
      return res.status(500).json({ error: "Session error" });
    }

    req.session.userId = user.userId;
    req.session.email = user.email;
    req.session.displayName = user.displayName;
    req.session.avatar = user.avatar;

    req.session.save((err) => {
      if (err) {
        console.error("Session save error:", err);
        return res.status(500).json({ error: "Session error" });
      }
      return res.json({
        userId: user.userId,
        email: user.email,
        displayName: user.displayName,
        avatar: user.avatar,
      });
    });
  });
});
```

### Client implementation

On your app's main page, add this logic to detect and exchange the auth code on load:

```javascript
// On page load, check for auth_code in the URL
const params = new URLSearchParams(window.location.search);
const authCode = params.get("auth_code");

if (authCode) {
  // Clean the code from the URL immediately
  window.history.replaceState({}, "", window.location.pathname);

  // Exchange the code for a session
  fetch("/api/auth/exchange", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "include",
    body: JSON.stringify({ code: authCode }),
  })
    .then((res) => res.json())
    .then((data) => {
      if (data.error) {
        console.error("Auth exchange failed:", data.error);
      } else {
        // Session is now established — reload or update UI
        window.location.reload();
      }
    })
    .catch((err) => console.error("Auth exchange error:", err));
}
```

### Why this is more reliable

| | Basic pattern | Auth code exchange |
|---|---|---|
| Session set during | 3xx redirect response | Normal 200 POST response |
| `Set-Cookie` reliability | Can silently fail on redirects | Reliable on standard responses |
| Works with `saveUninitialized: false` | Sometimes fails | Always works |
| Extra complexity | None | Small (auth code Map + exchange endpoint) |
| Proven on Replit | Intermittent failures reported | Production-proven (files.punch-drunk.com) |

If you're experiencing a login loop where everything looks correct but sessions aren't persisting, switching to this pattern is the recommended fix.

## Replit Deployment Gotchas

These are common issues that cause a "double-login" problem — users authenticate successfully at Punch Drunk Auth, get redirected back, but your app doesn't recognize them as logged in. All three must be addressed.

### 1. Missing `trust proxy` (most common cause)

Replit puts a reverse proxy in front of your app. The proxy handles HTTPS, but Express only sees HTTP. When session cookies have `secure: true`, Express refuses to set the cookie because it thinks the connection isn't secure.

**Fix:** Add this line BEFORE your session middleware:

```javascript
app.set("trust proxy", 1);
```

Without this, `secure: true` cookies silently fail. This is the #1 cause of double-login on Replit.

### 2. Session cookie misconfiguration

Four settings that must all be correct:

| Setting | Value | Why |
|---------|-------|-----|
| `name` | A unique string (e.g., `"myapp.sid"`) | **Critical for subdomain apps.** PD Auth sets its own `connect.sid` cookie on `.punch-drunk.com`. If your app uses the default name, the browser sends PD Auth's cookie to your app, and your app tries to read a session that doesn't exist in its own store. Use a unique name per app. |
| `secure` | `process.env.NODE_ENV === "production"` | Must be `true` in production (HTTPS), `false` in dev (HTTP). Do NOT hardcode `false`. |
| `sameSite` | `"lax"` | Allows cookies on redirect-back from auth. `"strict"` blocks the cookie on the redirect from Punch Drunk Auth. |
| `httpOnly` | `true` | Prevents JavaScript access to the cookie (security best practice). |

### 3. MemoryStore loses sessions on restart

Express's default `MemoryStore` is not production-ready. Sessions are lost whenever your app restarts (which happens frequently on Replit during deployments). Use `connect-pg-simple` to store sessions in PostgreSQL.

**Packages needed:**

```
connect-pg-simple
pg
```

**Critical:** Set `createTableIfMissing: false` and create the session table manually. The `createTableIfMissing: true` option tries to read a `table.sql` file from disk that doesn't exist in Replit's `dist/` build output, causing `ENOENT` errors and a complete login loop.

**Required first-deploy step — easy to forget, silent failure if missed:**

The `session` table is **not** managed by your ORM (Drizzle, Prisma, etc.). You must create it manually before deploying. If this table doesn't exist, `connect-pg-simple` silently fails to persist sessions — `session.save()` passes an error to its callback, but unless you explicitly handle and log it, nothing visibly breaks. The session middleware just doesn't persist anything, `/api/me` returns unauthenticated on every request, and users appear to need to log in twice. This is one of the hardest double-login bugs to diagnose because there are no loud errors.

Run this SQL once against your database **before your first deploy**:

```sql
CREATE TABLE IF NOT EXISTS "session" (
  "sid" varchar NOT NULL COLLATE "default",
  "sess" json NOT NULL,
  "expire" timestamp(6) NOT NULL,
  CONSTRAINT "session_pkey" PRIMARY KEY ("sid")
);
CREATE INDEX IF NOT EXISTS "IDX_session_expire" ON "session" ("expire");
```

To verify the table exists, run: `SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'session';` — the result should be `1`.

### 4. Race condition — save session before redirect

Express sessions save asynchronously. If you set `req.session.user` and immediately call `res.redirect()`, the redirect can fire before the session is written to the database. The user lands on the next page with no session.

**Fix:** Always wrap redirects in `req.session.save()`:

```javascript
// WRONG — race condition
req.session.user = data.user;
res.redirect("/");

// CORRECT — session is saved before redirect fires
req.session.user = data.user;
req.session.save((err) => {
  if (err) console.error("Session save error:", err);
  res.redirect("/");
});
```

This applies to ALL routes that modify the session and then redirect — not just the auth callback.

### 5. Redirect URI must use your canonical domain

If your app has a custom domain (e.g., `myapp.punch-drunk.com`), set `PUNCH_DRUNK_REDIRECT_URI` to the custom domain URL — **not** the `.replit.app` URL.

```
# WRONG — uses the Replit domain
PUNCH_DRUNK_REDIRECT_URI=https://myapp-username.replit.app/auth/callback

# CORRECT — uses your canonical/custom domain
PUNCH_DRUNK_REDIRECT_URI=https://myapp.punch-drunk.com/auth/callback
```

If the redirect URI points to the Replit domain but your app has a canonical redirect middleware that redirects `.replit.app` → custom domain, the auth callback takes a double-hop (Punch Drunk Auth → Replit domain → canonical domain). This can strip query parameters or cause cookie issues. Always register and use your canonical domain.

### 6. Logout redirects to `/auth/callback` instead of the app root

A common mistake: the logout route builds `redirect_to` from `PUNCH_DRUNK_REDIRECT_URI`, which points to `/auth/callback`. After logout, Punch Drunk Auth redirects the user back to `/auth/callback` with no token — resulting in a "Missing token" error page.

```javascript
// WRONG — sends users to /auth/callback after logout
const redirect = encodeURIComponent(process.env.PUNCH_DRUNK_REDIRECT_URI);

// CORRECT — sends users to the app root
const appBase = process.env.PUNCH_DRUNK_REDIRECT_URI
  ? new URL(process.env.PUNCH_DRUNK_REDIRECT_URI).origin
  : "https://yourapp.replit.app";
const redirect = encodeURIComponent(appBase);
```

### 7. Callback errors show raw error pages

If the auth callback returns `res.status(401).send("Authentication failed")` on token validation failure, users see a plain text error page with no navigation. They have to manually go back and retry.

**Fix:** Redirect to your landing page with an error query parameter instead:

```javascript
// WRONG — dead-end error page
if (!data.valid) return res.status(401).send("Authentication failed");

// CORRECT — redirect with error context
if (!data.valid) return res.redirect("/?auth_error=invalid_token");
```

Then on your frontend landing page, read the `auth_error` param on mount, display a user-friendly error message, and clear the param from the URL so it doesn't persist on refresh:

```javascript
const params = new URLSearchParams(window.location.search);
const err = params.get("auth_error");
if (err) {
  // Display appropriate error message to the user
  window.history.replaceState({}, "", window.location.pathname);
}
```

### 8. Use `session.regenerate()` before setting session fields on login

Setting fields directly on an existing `req.session` and calling `save()` works in the happy path, but can cause intermittent save failures when a user has a stale session from a previous failed login attempt. The old session ID gets reused, which can lead to inconsistent state in the session store. This is also a session fixation vulnerability.

**Fix:** Call `session.regenerate()` before setting any fields. It destroys the old session, creates a fresh session ID, and then you set your fields on the clean session:

```javascript
// WRONG — reuses existing session ID
req.session.userId = data.user.userId;
req.session.save((err) => { ... });

// CORRECT — fresh session ID, prevents session fixation
req.session.regenerate((err) => {
  if (err) return res.redirect("/?auth_error=server_error");

  req.session.userId = data.user.userId;
  // ... set other fields ...

  req.session.save((err) => {
    if (err) return res.redirect("/?auth_error=server_error");
    res.redirect("/");
  });
});
```

### 9. Skip the auth flow if the user already has a session

If a logged-in user visits `/login` directly (e.g., typed the URL, or client code redirected them there), the server sends them through the full Punch Drunk Auth flow unnecessarily. PD Auth sees their active session and immediately redirects back — an unnecessary round-trip that can look like a flash or glitch.

**Fix:** Check for an existing session at the top of the `/login` handler:

```javascript
app.get("/login", (req, res) => {
  if (req.session.userId) return res.redirect("/");
  // ... proceed with PD Auth redirect ...
});
```

### 10. Session cookie name collision on `*.punch-drunk.com` subdomains

**Critical.** Punch Drunk Auth sets its own `connect.sid` session cookie on the `.punch-drunk.com` domain. If your app runs on a subdomain of `punch-drunk.com` (e.g., `crewsheet.punch-drunk.com`, `files.punch-drunk.com`) and uses Express's default session cookie name (`connect.sid`), the browser will send PD Auth's cookie to your app on every request. Your app tries to look up that session ID in its own session store, finds nothing, and treats the user as unauthenticated — even though they just logged in.

This results in a login loop with no errors in server logs. The session data your app writes is correct, but it's being read back under the wrong cookie.

**Fix:** Set a unique `name` in your session configuration:

```javascript
app.use(session({
  name: "crewsheet.sid",  // UNIQUE per app — NOT "connect.sid"
  secret: process.env.SESSION_SECRET,
  store: new pgSession({ /* ... */ }),
  cookie: {
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    httpOnly: true
  },
  resave: false,
  saveUninitialized: false
}));
```

Choose any unique name — `"myapp.sid"`, `"files.sid"`, `"yourappname.sid"`, etc. Apps on `.replit.app` domains are not affected by this issue since they don't share a parent domain with PD Auth, but using a unique name is still good practice.

### 11. `Set-Cookie` silently fails on redirect responses (`saveUninitialized: false`)

When `express-session` is configured with `saveUninitialized: false` (the recommended setting), setting session data during a 3xx redirect response can silently fail to send the `Set-Cookie` header. The session data is written to the database, but the browser never receives the cookie. On the next request, the server sees no session cookie and treats the user as unauthenticated — a login loop with no visible errors.

This is the most insidious login loop because everything on the server side looks correct: token validation succeeds, `session.regenerate()` succeeds, `session.save()` succeeds, and the redirect fires. But the browser simply doesn't have the cookie.

**Fix:** Use the **Auth Code Exchange Pattern** (see section above). It avoids setting session data during any redirect. Instead, the callback generates a one-time code, the client exchanges it via a normal POST request, and the `Set-Cookie` header is sent on a standard 200 response — which is always reliable.

If you've checked all other gotchas (1–9) and users still can't log in, this is almost certainly the cause. Switch to the auth code exchange pattern.

### Quick Diagnostic

If users have to log in twice, or see errors after login/logout, check these in order:

1. Is `app.set("trust proxy", 1)` present? → If no, add it.
2. Is the session cookie `name` unique (not `"connect.sid"`)? → If on a `.punch-drunk.com` subdomain, this is **critical**. Set a unique name like `"myapp.sid"`.
3. Is `secure` set to `process.env.NODE_ENV === "production"`? → If hardcoded `false`, fix it.
4. Is `sameSite` set to `"lax"`? → If `"strict"` or missing, fix it.
5. Are you using MemoryStore? → Switch to `connect-pg-simple`.
6. Does the `session` table exist in the database? → Run the SQL from "Session Table Setup" if not.
7. Is `req.session.save()` called before `res.redirect()`? → If not, wrap it.
8. Does `PUNCH_DRUNK_REDIRECT_URI` use your canonical/custom domain? → If it uses `.replit.app`, change it.
9. Does the logout route redirect to the app root (not `/auth/callback`)? → If it uses `PUNCH_DRUNK_REDIRECT_URI` directly, extract the origin.
10. Does the callback return raw error pages on failure? → Redirect to `/?auth_error=...` instead.
11. Is `req.session.regenerate()` called before setting session fields? → Wrap the field assignments in `regenerate()`.
12. Does the `/login` route check for an existing session? → Add `if (req.session.userId) return res.redirect("/")` at the top.
13. Still looping after all of the above? → Switch to the **Auth Code Exchange Pattern**. The `Set-Cookie` header may be silently failing on redirect responses.

## Quick Checklist

### Redirect Flow (Traditional)
- [ ] Register your app in the Punch Drunk Auth dashboard (Connected Apps page)
- [ ] Set `PUNCH_DRUNK_API_KEY` as environment secret
- [ ] Set `PUNCH_DRUNK_REDIRECT_URI` to your callback URL (must match registered URL)
- [ ] Add `app.set("trust proxy", 1)` before session middleware
- [ ] Configure session with PostgreSQL store (`connect-pg-simple`), not MemoryStore
- [ ] Set a **unique session cookie name** (e.g., `"myapp.sid"`) — do NOT use the default `"connect.sid"`
- [ ] Set cookie: `secure: process.env.NODE_ENV === "production"`, `sameSite: "lax"`, `httpOnly: true`
- [ ] Create the `session` table in your database (see SQL above)
- [ ] Implement `/login` route that checks for existing session first, then redirects to `/authorize`
- [ ] Implement `/auth/callback` route that validates the token, calls `req.session.regenerate()` then `req.session.save()` before redirecting, and redirects to `/?auth_error=...` on failures (never raw error pages)
- [ ] Implement `/logout` route that clears session and redirects to Punch Drunk Auth logout using the app root URL (not `/auth/callback`)
- [ ] Handle `auth_error` query param on your landing page (display message, clear URL param)
- [ ] Add auth middleware to protect routes
- [ ] If using a custom domain, ensure `PUNCH_DRUNK_REDIRECT_URI` uses the custom domain (not `.replit.app`)

### Auth Code Exchange Pattern (Recommended for Replit)
- [ ] All items from the Traditional checklist above (except the callback implementation)
- [ ] Implement `/auth/callback` route that generates a one-time auth code and redirects to `/?auth_code=CODE`
- [ ] Implement `POST /api/auth/exchange` endpoint that trades auth code for a session
- [ ] Add client-side logic to detect `auth_code` in URL, exchange it via POST, and reload

### Optional Enhancements
- [ ] Set up local user table with `punch_drunk_user_id` mapping
- [ ] Embed account widget for in-app profile management (`PUNCH_DRUNK_SECRET_KEY` required)
- [ ] Set `PUNCH_DRUNK_SECRET_KEY` only if using the embed account widget
