devlyn-cli 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,30 @@
1
1
  ---
2
2
  name: better-auth-setup
3
3
  description: >
4
- Production-ready Better Auth integration for Bun + Hono + Drizzle + PostgreSQL projects.
5
- Sets up email/password auth, session cookies, API key auth, organization/multi-tenant support,
6
- email verification, CORS, security headers, auth middleware, tenant context, and test infrastructure
7
- in one pass with zero gotchas. Use this skill whenever setting up authentication in a new
8
- Hono/Bun project, adding Better Auth to an existing project, or when the user mentions Better Auth,
9
- auth setup, login, signup, session management, API keys, or multi-tenant auth. Also use when
10
- debugging auth issues in a Hono + Better Auth stack.
4
+ Production-ready Better Auth integration for fullstack projects. Covers both the backend
5
+ (Bun + Hono + Drizzle + PostgreSQL) and the frontend reverse proxy architecture (Next.js,
6
+ Cloudflare Workers, or any framework proxying auth requests to a separate backend). Sets up
7
+ email/password auth, session cookies, OAuth providers (Google, GitHub), API key auth,
8
+ organization/multi-tenant support, email verification, CORS, security headers, auth middleware,
9
+ tenant context, proxy forwarding headers, dynamic baseURL with allowedHosts, cookie prefix
10
+ handling, and test infrastructure — all in one pass with zero gotchas. Use this skill whenever
11
+ setting up Better Auth, adding OAuth/social login, configuring a reverse proxy for auth,
12
+ debugging redirect_uri_mismatch errors, fixing state_mismatch cookie issues, session cookies
13
+ not persisting after OAuth callback, or when the user mentions Better Auth, OAuth proxy,
14
+ auth setup, login, signup, session management, API keys, multi-tenant auth, or
15
+ "session cookie not working".
11
16
  allowed-tools: Read, Grep, Glob, Edit, Write, Bash
12
- argument-hint: "[new-project-path or 'debug']"
17
+ argument-hint: "[new-project-path | 'debug' | 'proxy']"
13
18
  ---
14
19
 
15
20
  # Better Auth Production Setup
16
21
 
17
- Set up a complete, production-hardened authentication system using Better Auth with Bun + Hono + Drizzle ORM + PostgreSQL. This skill incorporates lessons from real production deployments where each "gotcha" caused hours of debugging.
22
+ Set up a complete, production-hardened authentication system using Better Auth. This skill covers two deployment architectures:
18
23
 
19
- The setup produces a dual-auth system: session cookies for browser users and API keys for programmatic access, with multi-tenant organization support and plan-based access control.
24
+ 1. **Backend-only** Better Auth running directly in a Hono + Bun + Drizzle + PostgreSQL API server
25
+ 2. **Fullstack with proxy** — A separate frontend (e.g., Next.js on Cloudflare Workers) that proxies auth requests to the backend
26
+
27
+ The setup produces a dual-auth system: session cookies for browser users and API keys for programmatic access, with multi-tenant organization support and plan-based access control. Every configuration choice addresses a real production gotcha that caused hours of debugging.
20
28
 
21
29
  ## Reference Files
22
30
 
@@ -27,17 +35,35 @@ Read these when each step directs you to them:
27
35
  - `${CLAUDE_SKILL_DIR}/references/api-keys.md` — Key generation, CRUD routes, security patterns
28
36
  - `${CLAUDE_SKILL_DIR}/references/config-and-entry.md` — Env config, error types, entry point wiring
29
37
  - `${CLAUDE_SKILL_DIR}/references/testing.md` — Test preload, seed factory, integration patterns
38
+ - `${CLAUDE_SKILL_DIR}/references/proxy-setup.md` — Reverse proxy architecture, forwarding headers, OAuth callback routing
39
+ - `${CLAUDE_SKILL_DIR}/references/proxy-gotchas.md` — Proxy-specific troubleshooting (redirect_uri_mismatch, state_mismatch, cookie prefix issues)
30
40
 
31
41
  ## Handling Input
32
42
 
33
43
  Parse `$ARGUMENTS` to determine the mode:
34
44
 
35
- - **Empty or project path**: Run the full setup workflow (Steps 1-11)
45
+ - **Empty or project path**: Detect architecture, then run the full setup workflow
36
46
  - **`debug`**: Skip to the verification checklist (Step 11) to diagnose an existing setup
47
+ - **`proxy`**: Skip to proxy-specific steps (Steps 12-14) for an existing backend
37
48
  - **Specific step number** (e.g., `step 3`): Jump to that step for targeted work
38
49
 
39
50
  If `$ARGUMENTS` is empty, ask the user for the project path or confirm the current directory.
40
51
 
52
+ ## Step 0: Detect Architecture
53
+
54
+ Before starting, determine the deployment architecture:
55
+
56
+ 1. Check if the project has a **separate frontend** that proxies to the backend (look for Next.js, proxy routes, `API_URL` env vars)
57
+ 2. Check if the **current project IS the backend** (Hono, Express, or similar server framework with Better Auth)
58
+
59
+ | Architecture | Signals | Steps |
60
+ |---|---|---|
61
+ | Backend-only | Hono/Express project, no frontend proxy | Steps 1-11 |
62
+ | Frontend proxy (setting up frontend) | Next.js/Remix project with `API_URL` pointing to a backend | Steps 12-14 |
63
+ | Fullstack (both) | Both projects accessible | Steps 1-14 |
64
+
65
+ If uncertain, ask the user which architecture they're using.
66
+
41
67
  ---
42
68
 
43
69
  ## Workflow
@@ -111,6 +137,9 @@ export const auth = betterAuth({
111
137
  // Always set basePath explicitly — missing this causes route mismatches
112
138
  // between where Hono mounts auth routes and where Better Auth handles them.
113
139
  basePath: "/auth",
140
+
141
+ // For backend-only: use a static baseURL.
142
+ // For proxy architecture: use allowedHosts (see Step 12).
114
143
  baseURL: config.BETTER_AUTH_URL,
115
144
  secret: config.BETTER_AUTH_SECRET,
116
145
 
@@ -414,8 +443,163 @@ Always append a unique suffix (UUID slice or timestamp) to prevent collisions.
414
443
 
415
444
  ---
416
445
 
446
+ ---
447
+
448
+ ## Proxy Architecture (Steps 12-14)
449
+
450
+ These steps apply when the frontend is a separate application that proxies auth requests to the backend. Read `${CLAUDE_SKILL_DIR}/references/proxy-setup.md` for the complete guide with code examples.
451
+
452
+ ### Step 12: Configure Dynamic baseURL on Backend
453
+
454
+ **Entry:** Backend auth works directly (Steps 1-11 complete or existing backend).
455
+ **Exit:** Backend derives baseURL per-request from forwarded headers.
456
+
457
+ Replace the static `baseURL` in `src/lib/auth.ts` with `allowedHosts`:
458
+
459
+ ```typescript
460
+ export const auth = betterAuth({
461
+ baseURL: {
462
+ allowedHosts: [
463
+ "your-frontend.com", // Frontend domain (via proxy)
464
+ "your-backend.fly.dev", // Backend domain (direct access)
465
+ "localhost", // Local development
466
+ "*.fly.dev", // Platform internal routing
467
+ ],
468
+ fallback: process.env.BETTER_AUTH_URL,
469
+ },
470
+ basePath: "/auth",
471
+ advanced: {
472
+ trustedProxyHeaders: true, // Read X-Forwarded-Host from the proxy
473
+ },
474
+ // ... rest of config unchanged
475
+ });
476
+ ```
477
+
478
+ **Why `allowedHosts` instead of a static baseURL:**
479
+ - A static `baseURL` caches on first request (often a health check with internal hostname)
480
+ - `trustedProxyHeaders: true` alone does NOT work when `baseURL` is set — the static value takes precedence
481
+ - The `BETTER_AUTH_URL` env var also overrides forwarded headers
482
+ - `allowedHosts` derives baseURL per-request and never caches
483
+
484
+ Add Google OAuth (or other social providers):
485
+
486
+ ```typescript
487
+ socialProviders: {
488
+ google: {
489
+ clientId: process.env.GOOGLE_CLIENT_ID!,
490
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
491
+ },
492
+ },
493
+ ```
494
+
495
+ ---
496
+
497
+ ### Step 13: Configure Frontend Proxy
498
+
499
+ **Entry:** Backend uses `allowedHosts` with `trustedProxyHeaders`.
500
+ **Exit:** Frontend proxies auth requests and OAuth callbacks correctly.
501
+
502
+ The frontend proxy must do three things. Read `${CLAUDE_SKILL_DIR}/references/proxy-setup.md` for complete code.
503
+
504
+ #### 13a. Create two proxy routes
505
+
506
+ | Frontend Route | Backend Route | Purpose |
507
+ |---|---|---|
508
+ | `/api/auth/*` | `/auth/*` | Auth-client API calls (browser) |
509
+ | `/auth/*` | `/auth/*` | OAuth callbacks (Google redirects here) |
510
+
511
+ The second route is critical — Better Auth constructs OAuth callback URLs from `{baseURL}{basePath}/callback/{provider}`. When it derives baseURL from the frontend origin, the callback becomes `https://your-frontend.com/auth/callback/google`.
512
+
513
+ #### 13b. Set forwarding headers (not just strip them)
514
+
515
+ ```typescript
516
+ // Strip user-provided headers to prevent spoofing
517
+ headers.delete("x-forwarded-for");
518
+ headers.delete("x-forwarded-host");
519
+ headers.delete("x-forwarded-proto");
520
+ headers.delete("x-real-ip");
521
+ headers.delete("cf-connecting-ip");
522
+
523
+ // Then set correct values from the actual request
524
+ headers.set("x-forwarded-host", url.host);
525
+ headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
526
+ ```
527
+
528
+ #### 13c. Rewrite Location headers
529
+
530
+ When the backend returns a redirect, rewrite the `Location` header to point to the frontend origin.
531
+
532
+ ---
533
+
534
+ ### Step 14: Configure Frontend Middleware
535
+
536
+ **Entry:** Proxy routes working.
537
+ **Exit:** Middleware detects session cookies correctly, including `__Secure-` prefix.
538
+
539
+ In production with HTTPS, Better Auth adds a `__Secure-` prefix to cookie names. Your middleware MUST check for both:
540
+
541
+ ```typescript
542
+ const SESSION_COOKIES = [
543
+ "__Secure-better-auth.session_token", // Production (HTTPS)
544
+ "better-auth.session_token", // Development (HTTP)
545
+ ];
546
+
547
+ const hasSession = SESSION_COOKIES.some(
548
+ (name) => !!request.cookies.get(name)?.value,
549
+ );
550
+ ```
551
+
552
+ This is the most insidious gotcha in the proxy architecture — OAuth completes successfully, session is created, cookie is set, but the frontend middleware doesn't recognize it because it only checks the unprefixed name.
553
+
554
+ Configure the OAuth provider (Google Console example):
555
+ - **Authorized JavaScript origins**: `https://your-frontend.com`
556
+ - **Authorized redirect URIs**: `https://your-frontend.com/auth/callback/google`
557
+
558
+ The redirect URI uses `/auth/callback/google`, NOT `/api/auth/callback/google`.
559
+
560
+ ---
561
+
562
+ ### Step 15: Verify Proxy Setup
563
+
564
+ In addition to the Step 11 checklist, verify:
565
+
566
+ **Proxy**
567
+ - [ ] Proxy route exists at `/api/auth/*` (for auth-client API calls)
568
+ - [ ] Proxy route exists at `/auth/*` (for OAuth callbacks)
569
+ - [ ] Proxy strips THEN sets `X-Forwarded-Host` and `X-Forwarded-Proto`
570
+ - [ ] Proxy rewrites `Location` headers from backend origin to frontend origin
571
+
572
+ **Dynamic baseURL**
573
+ - [ ] Backend uses `allowedHosts` (not a static `baseURL` string)
574
+ - [ ] `allowedHosts` includes frontend domain, backend domain, and `localhost`
575
+ - [ ] `fallback` is set for health checks
576
+ - [ ] `trustedProxyHeaders: true` is in `advanced` config
577
+
578
+ **OAuth**
579
+ - [ ] Google Console redirect URI is `https://{frontend}/auth/callback/google`
580
+ - [ ] Verification curl returns `redirect_uri` with frontend domain (not backend)
581
+
582
+ **Cookies**
583
+ - [ ] Middleware checks both `__Secure-better-auth.session_token` and `better-auth.session_token`
584
+
585
+ Test with:
586
+ ```bash
587
+ curl -s -X POST "https://your-frontend.com/api/auth/sign-in/social" \
588
+ -H "Content-Type: application/json" \
589
+ -d '{"provider":"google","callbackURL":"/dashboard"}' | \
590
+ python3 -c "import sys,json,urllib.parse; data=json.load(sys.stdin); url=data.get('url',''); params=urllib.parse.parse_qs(urllib.parse.urlparse(url).query); print('redirect_uri:', params.get('redirect_uri',['N/A'])[0])"
591
+ ```
592
+
593
+ Expected: `redirect_uri: https://your-frontend.com/auth/callback/google`
594
+
595
+ For detailed troubleshooting of proxy-specific failures, read `${CLAUDE_SKILL_DIR}/references/proxy-gotchas.md`.
596
+
597
+ ---
598
+
417
599
  ## Quick Reference: Common Mistakes
418
600
 
601
+ ### Backend Mistakes
602
+
419
603
  | Mistake | Consequence | Prevention |
420
604
  |---------|-------------|------------|
421
605
  | Missing `basePath` | Auth routes 404 | Set `basePath: "/auth"` explicitly |
@@ -428,6 +612,18 @@ Always append a unique suffix (UUID slice or timestamp) to prevent collisions.
428
612
  | Generic 403 for no org | Frontend can't show helpful UX | Distinct `no_organization` code |
429
613
  | `credentials: true` + `*` | Browser rejects response | Specific origins with credentials |
430
614
 
615
+ ### Proxy Mistakes
616
+
617
+ | Mistake | Consequence | Prevention |
618
+ |---------|-------------|------------|
619
+ | Static `baseURL` with proxy | OAuth callback uses wrong domain | Use `allowedHosts` + `fallback` |
620
+ | `trustedProxyHeaders` without `allowedHosts` | Static baseURL or env var overrides | `allowedHosts` bypasses priority chain |
621
+ | Only `/api/auth/*` proxy route | OAuth callback 404 (Google redirects to `/auth/callback/*`) | Add `/auth/*` proxy route |
622
+ | Proxy only strips headers | Backend doesn't know frontend origin | Strip THEN set `X-Forwarded-Host` |
623
+ | Middleware checks only `better-auth.session_token` | Authenticated users redirected to sign-in | Check both `__Secure-` prefixed and plain |
624
+ | Google Console redirect URI with `/api/auth/` | `redirect_uri_mismatch` from Google | Use `/auth/callback/google` (not `/api/auth/`) |
625
+ | `BETTER_AUTH_URL` env var set | Overrides forwarded headers | Use `allowedHosts` with `fallback` instead |
626
+
431
627
  <example>
432
628
  **User**: "Set up Better Auth in my new Hono project at ./my-api"
433
629
 
@@ -448,3 +644,18 @@ Always append a unique suffix (UUID slice or timestamp) to prevent collisions.
448
644
  14. Create test utilities (setup.ts, db.ts, app.ts)
449
645
  15. Run verification checklist
450
646
  </example>
647
+
648
+ <example>
649
+ **User**: "I have a Next.js frontend and Hono backend. I need Google OAuth working through the proxy."
650
+
651
+ **Steps taken**:
652
+ 1. Detect proxy architecture (Step 0)
653
+ 2. Update backend auth.ts: replace static baseURL with allowedHosts + trustedProxyHeaders (Step 12)
654
+ 3. Add Google social provider to auth config (Step 12)
655
+ 4. Create /api/auth/* and /auth/* proxy routes on frontend (Step 13)
656
+ 5. Set forwarding headers in proxy (Step 13)
657
+ 6. Add Location header rewriting (Step 13)
658
+ 7. Update frontend middleware to check both cookie names (Step 14)
659
+ 8. Configure Google Console redirect URI (Step 14)
660
+ 9. Run proxy verification checklist (Step 15)
661
+ </example>
@@ -0,0 +1,148 @@
1
+ # Better Auth + Reverse Proxy — Gotchas & Troubleshooting
2
+
3
+ Common failures when running Better Auth behind a reverse proxy, with root causes and fixes. Each gotcha was discovered through real debugging — they are not obvious from the docs.
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [redirect_uri_mismatch from Google](#1-redirect_uri_mismatch-from-google)
8
+ 2. [state_mismatch after OAuth callback](#2-state_mismatch-after-oauth-callback)
9
+ 3. [404 on all auth endpoints](#3-404-on-all-auth-endpoints)
10
+ 4. [Session cookie not detected after login](#4-session-cookie-not-detected-after-login)
11
+ 5. [trustedProxyHeaders not working](#5-trustedproxyheaders-not-working)
12
+ 6. [baseURL caches wrong origin](#6-baseurl-caches-wrong-origin)
13
+ 7. [BETTER_AUTH_URL env var overrides everything](#7-better_auth_url-env-var-overrides-everything)
14
+ 8. [OAuth callback hits backend directly (skips proxy)](#8-oauth-callback-hits-backend-directly-skips-proxy)
15
+
16
+ ---
17
+
18
+ ## 1. redirect_uri_mismatch from Google
19
+
20
+ **Symptom**: Google OAuth returns `redirect_uri_mismatch` error page.
21
+
22
+ **Root cause**: The `redirect_uri` Better Auth sends to Google doesn't match what's configured in Google Console. This happens when Better Auth derives the wrong baseURL — typically the backend's domain instead of the frontend's.
23
+
24
+ **Debug**: Run the verification curl to see what `redirect_uri` is actually being sent:
25
+ ```bash
26
+ curl -s -X POST "https://your-frontend.com/api/auth/sign-in/social" \
27
+ -H "Content-Type: application/json" \
28
+ -d '{"provider":"google","callbackURL":"/dashboard"}' | \
29
+ python3 -c "import sys,json,urllib.parse; data=json.load(sys.stdin); url=data.get('url',''); params=urllib.parse.parse_qs(urllib.parse.urlparse(url).query); print('redirect_uri:', params.get('redirect_uri',['N/A'])[0])"
30
+ ```
31
+
32
+ Compare the output to Google Console's "Authorized redirect URIs".
33
+
34
+ **Fix**:
35
+ - Ensure `allowedHosts` includes the frontend domain
36
+ - Ensure the proxy sets `X-Forwarded-Host` header (not just strips it)
37
+ - Ensure `trustedProxyHeaders: true` is in the `advanced` config
38
+ - Google Console redirect URI must be `https://{frontend}/auth/callback/{provider}` — note `/auth/`, NOT `/api/auth/`
39
+
40
+ **Google Console propagation**: Changes take up to 5 minutes. If you just updated, wait and retry.
41
+
42
+ ---
43
+
44
+ ## 2. state_mismatch after OAuth callback
45
+
46
+ **Symptom**: After Google redirects back, Better Auth returns a `state_mismatch` error, typically visible at the backend URL.
47
+
48
+ **Root cause**: The state cookie was set on the frontend domain during OAuth initiation, but the callback arrived directly at the backend domain. Different domain = no cookie = state mismatch.
49
+
50
+ **Fix**: The OAuth callback URL must route through the frontend proxy, not directly to the backend. This means:
51
+ 1. A proxy route at `/auth/*` must exist on the frontend (in addition to `/api/auth/*`)
52
+ 2. The `redirect_uri` sent to the OAuth provider must use the frontend domain
53
+ 3. Both the initial request and callback must share the same domain for cookies to work
54
+
55
+ **How to verify**: Check the `redirect_uri` in the OAuth initiation response. If it points to the backend domain, the `allowedHosts` or forwarding headers are misconfigured.
56
+
57
+ ---
58
+
59
+ ## 3. 404 on all auth endpoints
60
+
61
+ **Symptom**: POST `/api/auth/sign-in/social` (or any auth endpoint) returns 404.
62
+
63
+ **Root causes** (multiple possible):
64
+
65
+ ### 3a. baseURL includes a path component
66
+ Setting `baseURL` to something like `https://frontend.com/api` breaks Better Auth's internal routing. Better Auth uses `basePath` for path prefixing — `baseURL` should only be the origin (protocol + host).
67
+
68
+ ### 3b. Host mismatch with static baseURL
69
+ If `baseURL` is `https://frontend.com` but requests arrive at the backend with `Host: backend.fly.dev`, Better Auth may reject the request because the host doesn't match. This is why dynamic `allowedHosts` is needed.
70
+
71
+ ### 3c. allowedHosts array is empty or malformed
72
+ If the `allowedHosts` array doesn't match any incoming host AND no `fallback` is set, Better Auth has no baseURL to work with and may reject requests.
73
+
74
+ ### 3d. Missing proxy route
75
+ If only `/api/auth/*` is proxied but the request arrives at `/auth/*` (OAuth callback), there's no route handler.
76
+
77
+ **Fix**: Use `allowedHosts` with all known domains, always set `fallback`, and ensure both proxy routes exist.
78
+
79
+ ---
80
+
81
+ ## 4. Session cookie not detected after login
82
+
83
+ **Symptom**: OAuth completes successfully (no Google errors, callback returns 302 to `/dashboard`), but the user is immediately redirected to `/sign-in?redirect=%2Fdashboard`.
84
+
85
+ **Root cause**: In production with HTTPS, Better Auth prefixes cookie names with `__Secure-`. The cookie is actually named `__Secure-better-auth.session_token`, not `better-auth.session_token`. If middleware only checks the unprefixed name, it never finds the cookie.
86
+
87
+ **Why it happens with allowedHosts**: When `baseURL` is a dynamic config object (not a string), Better Auth can't determine the protocol at initialization time. In production (`NODE_ENV=production`), it defaults to secure cookies with the `__Secure-` prefix.
88
+
89
+ **Fix**: Middleware must check both cookie names:
90
+ ```typescript
91
+ const SESSION_COOKIES = [
92
+ "__Secure-better-auth.session_token",
93
+ "better-auth.session_token",
94
+ ];
95
+ ```
96
+
97
+ **This is the most insidious gotcha** because everything appears to work — OAuth succeeds, session is created, cookie is set — but the frontend doesn't recognize it. Without the skill, Claude diagnoses this as "Set-Cookie header stripping" (plausible but wrong) instead of the `__Secure-` prefix (correct).
98
+
99
+ ---
100
+
101
+ ## 5. trustedProxyHeaders not working
102
+
103
+ **Symptom**: Despite setting `trustedProxyHeaders: true`, Better Auth still uses the wrong origin for callback URLs.
104
+
105
+ **Root cause**: `trustedProxyHeaders` only takes effect when Better Auth actually reads forwarded headers. The `getBaseURL()` function has a priority order:
106
+ 1. Static `baseURL` string (if set) — **always wins**
107
+ 2. `BETTER_AUTH_URL` environment variable — **checked before headers**
108
+ 3. `X-Forwarded-Host` / `X-Forwarded-Proto` headers — only reached if 1 and 2 are absent
109
+ 4. Request URL — last resort
110
+
111
+ **Fix**: Use `allowedHosts` instead. It explicitly reads `X-Forwarded-Host` and constructs the baseURL per-request, bypassing the priority chain entirely.
112
+
113
+ ---
114
+
115
+ ## 6. baseURL caches wrong origin
116
+
117
+ **Symptom**: Auth works for the first request after deploy, then breaks. Or auth never works because the first request was a health check.
118
+
119
+ **Root cause**: When using a static `baseURL` string or `trustedProxyHeaders` without `allowedHosts`, Better Auth resolves the baseURL once from the first request and caches it. If the first request is a health check (which arrives with `Host: backend.internal`), all subsequent requests use that cached value.
120
+
121
+ **Fix**: Use `allowedHosts` — it derives baseURL per-request and never caches.
122
+
123
+ ---
124
+
125
+ ## 7. BETTER_AUTH_URL env var overrides everything
126
+
127
+ **Symptom**: You removed `baseURL` from the config to let `trustedProxyHeaders` work, but Better Auth still uses the wrong origin.
128
+
129
+ **Root cause**: Better Auth's `getBaseURL()` checks the `BETTER_AUTH_URL` environment variable before checking forwarded headers. If this env var is set (e.g., as a Fly.io secret), it takes precedence.
130
+
131
+ **Fix**: Either:
132
+ - Remove the `BETTER_AUTH_URL` env var entirely (risky — health checks need it)
133
+ - Use `allowedHosts` with `fallback: process.env.BETTER_AUTH_URL` — this bypasses the internal env var check while still having a fallback for non-proxied requests
134
+
135
+ ---
136
+
137
+ ## 8. OAuth callback hits backend directly (skips proxy)
138
+
139
+ **Symptom**: After Google OAuth, the browser lands on `https://backend.fly.dev/auth/callback/google` instead of going through the frontend proxy.
140
+
141
+ **Root cause**: Better Auth constructed the callback URL using the backend's origin instead of the frontend's. This means the forwarding headers weren't set correctly, or `allowedHosts` didn't match the frontend domain.
142
+
143
+ **Fix**:
144
+ 1. Verify the proxy sets `X-Forwarded-Host: frontend.com` (not just strips headers)
145
+ 2. Verify `allowedHosts` includes `frontend.com`
146
+ 3. Use the curl verification command to check what `redirect_uri` is being generated
147
+
148
+ **Quick debug**: The most common cause is that the proxy strips forwarding headers but doesn't set new ones. The proxy must do both: strip (prevent spoofing) then set (tell backend the real origin).
@@ -0,0 +1,284 @@
1
+ # Reverse Proxy Architecture — Complete Setup Guide
2
+
3
+ When the frontend (e.g., Next.js on Cloudflare Workers) is a separate application that proxies auth requests to the backend (e.g., Hono on Fly.io), several non-obvious configurations are required for OAuth, cookies, and redirects to work correctly.
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [Architecture Overview](#architecture-overview)
8
+ 2. [Proxy Route Configuration](#proxy-route-configuration)
9
+ 3. [Forwarding Headers](#forwarding-headers)
10
+ 4. [Location Header Rewriting](#location-header-rewriting)
11
+ 5. [Dynamic baseURL with allowedHosts](#dynamic-baseurl-with-allowedhosts)
12
+ 6. [Frontend Middleware Cookie Detection](#frontend-middleware-cookie-detection)
13
+ 7. [OAuth Provider Configuration](#oauth-provider-configuration)
14
+ 8. [CSP Configuration](#csp-configuration)
15
+ 9. [Edge Runtime (Cloudflare Workers)](#edge-runtime-cloudflare-workers)
16
+
17
+ ---
18
+
19
+ ## Architecture Overview
20
+
21
+ ```
22
+ Browser (your-frontend.com)
23
+ |
24
+ |-- POST /api/auth/sign-in/social --> Proxy --> Backend /auth/sign-in/social
25
+ | |
26
+ | v
27
+ | Sets state cookie
28
+ | Returns redirect to Google
29
+ |
30
+ |-- Browser redirects to Google OAuth
31
+ |
32
+ |-- Google redirects to callback URL
33
+ | (MUST go through proxy, not directly to backend)
34
+ |
35
+ |-- GET /auth/callback/google --> Proxy --> Backend /auth/callback/google
36
+ | |
37
+ | v
38
+ | Reads state cookie (same domain!)
39
+ | Creates session
40
+ | Sets session cookie
41
+ | Redirects to /dashboard
42
+ ```
43
+
44
+ The critical insight: **OAuth callbacks MUST go through the same proxy as the initial request**, so cookies (state and session) are on the same domain.
45
+
46
+ ---
47
+
48
+ ## Proxy Route Configuration
49
+
50
+ Two proxy routes are needed — one for API calls, one for OAuth callbacks:
51
+
52
+ | Frontend Route | Backend Route | Purpose |
53
+ |---|---|---|
54
+ | `/api/auth/*` | `/auth/*` | Auth-client API calls (browser SDK) |
55
+ | `/auth/*` | `/auth/*` | OAuth callbacks (provider redirects here) |
56
+
57
+ The auth-client (browser) sends requests to `/api/auth/*`. OAuth callbacks arrive at `/auth/*` because Better Auth constructs callback URLs from `{baseURL}{basePath}/callback/{provider}`, and the derived baseURL from `allowedHosts` is the frontend origin.
58
+
59
+ ### Next.js API Route Example
60
+
61
+ ```typescript
62
+ // src/app/api/auth/[...path]/route.ts
63
+ import { NextRequest, NextResponse } from "next/server";
64
+
65
+ const API_URL = process.env.API_URL; // e.g., "https://backend.fly.dev"
66
+
67
+ async function proxyRequest(request: NextRequest) {
68
+ const url = new URL(request.url);
69
+ const authPath = url.pathname.replace("/api/auth", "/auth");
70
+ const targetUrl = `${API_URL}${authPath}${url.search}`;
71
+
72
+ const headers = new Headers(request.headers);
73
+
74
+ // Strip then set forwarding headers (see next section)
75
+ headers.delete("x-forwarded-for");
76
+ headers.delete("x-forwarded-host");
77
+ headers.delete("x-forwarded-proto");
78
+ headers.delete("x-real-ip");
79
+ headers.delete("cf-connecting-ip");
80
+ headers.set("x-forwarded-host", url.host);
81
+ headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
82
+
83
+ const response = await fetch(targetUrl, {
84
+ method: request.method,
85
+ headers,
86
+ body: request.method !== "GET" && request.method !== "HEAD"
87
+ ? await request.text()
88
+ : undefined,
89
+ redirect: "manual",
90
+ });
91
+
92
+ const responseHeaders = new Headers(response.headers);
93
+
94
+ // Rewrite Location headers (see section below)
95
+ const location = responseHeaders.get("location");
96
+ if (location && API_URL && location.startsWith(API_URL)) {
97
+ const frontendOrigin = url.origin;
98
+ responseHeaders.set("location", location.replace(API_URL, frontendOrigin));
99
+ }
100
+
101
+ return new NextResponse(response.body, {
102
+ status: response.status,
103
+ headers: responseHeaders,
104
+ });
105
+ }
106
+
107
+ export const GET = proxyRequest;
108
+ export const POST = proxyRequest;
109
+ ```
110
+
111
+ ```typescript
112
+ // src/app/auth/[...path]/route.ts
113
+ // Same handler — reuse for OAuth callbacks
114
+ export { GET, POST } from "../api/auth/[...path]/route";
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Forwarding Headers
120
+
121
+ The proxy MUST set `X-Forwarded-Host` and `X-Forwarded-Proto` so Better Auth knows the real origin. This is how Better Auth constructs the correct OAuth callback URL.
122
+
123
+ ```typescript
124
+ // Strip user-provided headers to prevent spoofing
125
+ headers.delete("x-forwarded-for");
126
+ headers.delete("x-forwarded-host");
127
+ headers.delete("x-forwarded-proto");
128
+ headers.delete("x-real-ip");
129
+ headers.delete("cf-connecting-ip");
130
+
131
+ // Then set correct values from the actual request
132
+ headers.set("x-forwarded-host", url.host);
133
+ headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
134
+ ```
135
+
136
+ **Why both strip AND set?** Stripping prevents clients from spoofing these headers. Setting them tells the backend the true origin of the request. The proxy is the trusted boundary — it's the only thing that knows the real origin.
137
+
138
+ ---
139
+
140
+ ## Location Header Rewriting
141
+
142
+ When the backend returns a redirect (302), the `Location` header points to the backend's origin. The proxy must rewrite it to the frontend:
143
+
144
+ ```typescript
145
+ const location = responseHeaders.get("location");
146
+ if (location && API_URL && location.startsWith(API_URL)) {
147
+ const frontendOrigin = new URL(request.url).origin;
148
+ responseHeaders.set("location", location.replace(API_URL, frontendOrigin));
149
+ }
150
+ ```
151
+
152
+ Without this, redirects after OAuth callback would send the user to the backend domain instead of the frontend.
153
+
154
+ ---
155
+
156
+ ## Dynamic baseURL with allowedHosts
157
+
158
+ **DO NOT** use a static `baseURL` string in the backend's Better Auth config. It breaks the proxy architecture because:
159
+
160
+ - A static baseURL caches on first request (often a health check with `Host: backend.internal`), permanently setting the wrong origin
161
+ - `trustedProxyHeaders: true` alone does NOT work when `baseURL` is set — the static value takes precedence
162
+ - The `BETTER_AUTH_URL` env var also overrides forwarded headers (it's checked before headers in the priority chain)
163
+
164
+ **The correct approach** — use `allowedHosts` with a `fallback`:
165
+
166
+ ```typescript
167
+ export const auth = betterAuth({
168
+ baseURL: {
169
+ allowedHosts: [
170
+ "your-frontend.com", // Frontend domain (via proxy)
171
+ "your-backend.fly.dev", // Backend domain (direct access)
172
+ "localhost", // Local development
173
+ "*.fly.dev", // Platform internal routing
174
+ ],
175
+ fallback: process.env.BETTER_AUTH_URL, // For health checks / unmatched hosts
176
+ },
177
+ basePath: "/auth",
178
+
179
+ advanced: {
180
+ // Required for allowedHosts to read X-Forwarded-Host from the proxy
181
+ trustedProxyHeaders: true,
182
+ },
183
+ // ...
184
+ });
185
+ ```
186
+
187
+ **How allowedHosts works internally:**
188
+ 1. Reads `X-Forwarded-Host` header (set by proxy), falls back to `Host` header
189
+ 2. Validates against the allowedHosts list (supports wildcards like `*.fly.dev`)
190
+ 3. Constructs baseURL per-request (not cached!)
191
+ 4. If no match, uses `fallback`
192
+ 5. OAuth callback URL = `{derived-baseURL}{basePath}/callback/{provider}`
193
+
194
+ **The `getBaseURL()` priority chain** (why `trustedProxyHeaders` alone isn't enough):
195
+ 1. Static `baseURL` string (if set) — **always wins**
196
+ 2. `BETTER_AUTH_URL` environment variable — **checked before headers**
197
+ 3. `X-Forwarded-Host` / `X-Forwarded-Proto` headers — only reached if 1 and 2 are absent
198
+ 4. Request URL — last resort
199
+
200
+ `allowedHosts` bypasses this entire priority chain by explicitly reading the forwarded headers and constructing the URL per-request.
201
+
202
+ ---
203
+
204
+ ## Frontend Middleware Cookie Detection
205
+
206
+ In production with HTTPS, Better Auth adds a `__Secure-` prefix to cookie names. Your frontend middleware MUST check for both:
207
+
208
+ ```typescript
209
+ const SESSION_COOKIES = [
210
+ "__Secure-better-auth.session_token", // Production (HTTPS)
211
+ "better-auth.session_token", // Development (HTTP)
212
+ ];
213
+
214
+ const hasSession = SESSION_COOKIES.some(
215
+ (name) => !!request.cookies.get(name)?.value,
216
+ );
217
+ ```
218
+
219
+ **Why this happens:** When `baseURL` is a dynamic config object (allowedHosts), Better Auth can't determine the protocol at initialization time. In production (`NODE_ENV=production`), it defaults to secure cookies with the `__Secure-` prefix. If your middleware only checks for `better-auth.session_token`, it will never find the cookie and will redirect authenticated users to sign-in.
220
+
221
+ This is the most insidious gotcha in the proxy architecture — OAuth completes successfully, session is created, cookie is set, but the frontend doesn't recognize it.
222
+
223
+ ---
224
+
225
+ ## OAuth Provider Configuration
226
+
227
+ ### Google Console Settings
228
+
229
+ - **Authorized JavaScript origins**: `https://your-frontend.com`
230
+ - **Authorized redirect URIs**: `https://your-frontend.com/auth/callback/google`
231
+
232
+ The redirect URI uses `/auth/callback/google` (NOT `/api/auth/callback/google`) because Better Auth constructs it from `{baseURL}{basePath}/callback/google`, and the derived baseURL from `allowedHosts` is the frontend origin.
233
+
234
+ ### Google Console Propagation
235
+
236
+ Changes take up to 5 minutes to propagate. If you get `redirect_uri_mismatch` immediately after updating, wait and retry before changing configuration.
237
+
238
+ ### Backend Social Provider Config
239
+
240
+ ```typescript
241
+ socialProviders: {
242
+ google: {
243
+ clientId: process.env.GOOGLE_CLIENT_ID!,
244
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
245
+ },
246
+ // Add more as needed:
247
+ // github: { clientId: ..., clientSecret: ... },
248
+ },
249
+ ```
250
+
251
+ ---
252
+
253
+ ## CSP Configuration
254
+
255
+ If using Content Security Policy headers on the frontend, add required domains:
256
+
257
+ ```typescript
258
+ const cspDirectives = [
259
+ "default-src 'self'",
260
+ `script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com`,
261
+ "style-src 'self' 'unsafe-inline'",
262
+ "img-src 'self' data: https://lh3.googleusercontent.com", // Google avatars
263
+ "connect-src 'self'",
264
+ "font-src 'self'",
265
+ "frame-ancestors 'none'",
266
+ ];
267
+ ```
268
+
269
+ ---
270
+
271
+ ## Edge Runtime (Cloudflare Workers)
272
+
273
+ If deploying the frontend to Cloudflare Workers, the middleware must use the correct runtime:
274
+
275
+ ```typescript
276
+ // Next.js 16
277
+ export const runtime = "experimental-edge";
278
+ // NOT "edge" — Next.js 16 requires "experimental-edge"
279
+ ```
280
+
281
+ For earlier Next.js versions:
282
+ ```typescript
283
+ export const runtime = "edge";
284
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Claude Code configuration toolkit for teams",
5
5
  "bin": {
6
6
  "devlyn": "bin/devlyn.js"