解決 InsForge Auth 在 Next.js 的 Server-Side 陷阱:Client-Side 驗證架構完整指南解決 InsForge Auth 在 Next.js 的 Server-Side 陷阱:Client-Side 驗證架構完整指南
記錄 InsForge SDK 在 Next.js 專案中實作身份驗證時,因 httpOnly cookie domain 限制導致 server-side middleware 失效的問題與解法。適合使用 InsForge 或類似第三方 Auth 服務的 Next.js 開發者。
解決 InsForge Auth 在 Next.js 的 Server-Side 陷阱:Client-Side 驗證架構完整指南
適用技術棧:Next.js 16 (App Router) + @insforge/sdk ^1.1.6 + Hono 4 (API Routes) + TypeScript
前陣子在 Persyn 專案整合 InsForge Auth 時,踩了一個看起來理所當然但其實完全不 work 的坑:把 getCurrentSession() 放進 Next.js middleware 做 server-side auth guard。結果是登入永遠失敗,dev server 卡在 compiling,花了不少時間才從 SDK 原始碼裡找到根本原因。這篇整理了完整的問題分析和最終可用的架構,可以直接拿去用。
1. 這個系統做什麼
概覽
在 Next.js App Router 專案中實作完整的 InsForge 身份驗證:登入、註冊(含 OTP 信箱驗證)、登出、改密碼、auth guard(保護 dashboard 頁面)、已登入重導(避免重複登入)。同時支援雙重驗證機制,讓同一組 API 路由可以同時服務 dashboard(cookie session)和 CLI/外部工具(Bearer API key)。
解決什麼問題
InsForge SDK 的 getCurrentSession() 在 server-side(Node.js / Edge Runtime)永遠返回 session: null,因為 InsForge 的 auth cookie 是設在 InsForge 自己的 domain 上,不是你的 Next.js server 的 domain。如果你照 Supabase 的思路寫 Next.js middleware,會得到一個無限 redirect 或 compiling hang 的死局。
功能邊界(Scope)
- 包含:登入、註冊(OTP)、登出、密碼變更、client-side auth guard、server-side API session 驗證、API key 雙重驗證
- 不包含:OAuth 登入、忘記密碼 email flow、role-based access control
2. 整體架構與資料流
資料流
[瀏覽器]
|
|-- signInWithPassword() --> InsForge Backend (設 httpOnly cookie 在 InsForge domain)
| |
|-- getCurrentSession() --> InsForge Backend (瀏覽器帶 InsForge cookie, 拿回 accessToken)
|
|-- fetch('/api/v1/...') --> Next.js Server (帶你 domain 的 cookie)
|
|--> Hono sessionAuthMiddleware
| |
| |--> fetch InsForge /api/auth/refresh (轉發 cookie)
| |--> 拿到 user.id --> 設 context
|
|--> 或 Bearer token --> apiKeyAuthMiddleware
|
|--> HMAC lookup --> 拿到 user.id --> 設 context
來源檔案參考(僅供對照,非必要)
以下路徑僅標記程式碼出處,閱讀此 PRD 不需要存取這些檔案。
hooks/use-auth.ts | 核心邏輯 | Auth guard hooks(useAuth, useRequireAuth, useRedirectIfAuthed) |
lib/insforge-browser.ts | 入口 | 瀏覽器端 InsForge client singleton |
lib/insforge-server.ts | 入口 | Server 端 InsForge client factory |
src/middleware/session-auth.ts | 核心邏輯 | Hono session auth(直接 fetch InsForge refresh) |
src/middleware/dual-auth.ts | 核心邏輯 | 判斷 Bearer vs Cookie 走不同驗證路徑 |
src/middleware/auth.ts | 核心邏輯 | API key HMAC 驗證 |
app/(auth)/layout.tsx | UI | Auth 頁面 layout + 已登入重導 |
app/(auth)/login/page.tsx | UI | 登入表單 |
app/(auth)/register/page.tsx | UI | 註冊表單 + OTP 驗證 |
app/dashboard/layout.tsx | UI | Dashboard layout + auth guard |
外部依賴
| 依賴 | 類型 | 用途 |
|---|
@insforge/sdk | npm | Auth 核心(signUp, signIn, getCurrentSession, signOut) |
hono | npm | API 路由框架(middleware, context) |
next | npm | React 框架(App Router, useRouter) |
環境變數
# Server-side only(不要加 NEXT_PUBLIC_ 前綴)
INSFORGE_URL=https://your-project.region.insforge.app
INSFORGE_ANON_KEY=ik_your_anon_key
# Client-side(必須加 NEXT_PUBLIC_ 前綴,否則瀏覽器拿不到)
NEXT_PUBLIC_INSFORGE_URL=https://your-project.region.insforge.app
NEXT_PUBLIC_INSFORGE_ANON_KEY=ik_your_anon_key
# API key hashing 用
PERSYN_HMAC_SECRET=your_random_secret
3. 資料結構與型別定義
InsForge Session 結構
// getCurrentSession() 返回值
interface SessionResponse {
data: {
session: {
accessToken: string
user: {
id: string
email: string
name: string
created_at: string
}
} | null
}
error: InsForgeError | null
}
SignUp 回應
interface SignUpResponse {
data: {
user?: { id: string; email: string }
accessToken?: string
requireEmailVerification?: boolean // true 時要做 OTP 驗證
} | null
error: { message: string; statusCode?: number } | null
}
Auth Hook 回傳
interface AuthState {
user: {
id: string
email: string
name: string
created_at: string
} | null
loading: boolean
}
4. 一步步實作
坦白說這部分是整個功能最核心的地方。問題和解法都在這裡,建議照順序看一遍。
Step 1:理解為什麼 Server-Side Auth 不能用
InsForge SDK getCurrentSession() 的原始碼(簡化版):
async getCurrentSession() {
await this.authCallbackHandled // 等 OAuth callback 處理完
// 1. 先查記憶體有沒有 session
const session = this.tokenManager.getSession()
if (session) return { data: { session }, error: null }
// 2. 只在瀏覽器端才做 cookie refresh
if (typeof window !== "undefined") {
const response = await this.http.post("/api/auth/refresh", undefined, {
credentials: "include" // 瀏覽器自動帶 InsForge domain 的 cookie
})
if (response.accessToken) {
// 存到記憶體,返回 session
return { data: { session: newSession }, error: null }
}
}
// 3. Server-side 走到這裡,永遠返回 null
return { data: { session: null }, error: null }
}
關鍵在第 2 步的 if (typeof window !== "undefined")。在 Node.js / Edge Runtime 裡,window 不存在,所以 SDK 跳過 cookie refresh,直接返回 null。
而且就算你手動轉發 cookie 也沒用,因為 InsForge 的 httpOnly session cookie 是設在 InsForge 的 domain 上(例如 cy3aij5h.us-east.insforge.app),瀏覽器發請求到你的 localhost:3001 時,Cookie header 裡根本不包含 InsForge 的 cookie。
- 這跟 Supabase 不同。Supabase 有
@supabase/ssr 套件會把 cookie 代理到你的 domain 上,所以 Next.js middleware 可以讀到。InsForge 目前沒有這個機制。
- 如果你在
middleware.ts 裡呼叫 getCurrentSession() 然後根據結果 redirect,會造成無限迴圈:永遠判定未登入 → redirect 到 login → 用戶已登入又被推到 dashboard → 又判定未登入。
- Next.js dev mode 下這個迴圈會表現為「卡在 compiling」,而不是明顯的 redirect loop,很難 debug。
Step 2:建立 Client-Side Auth Guard Hooks
既然 server-side 不能用,所有 auth redirect 邏輯都要放在 client component 裡。
// hooks/use-auth.ts
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { createBrowserInsForgeClient } from '@/lib/insforge-browser'
// 基礎 hook:取得當前 session
export function useAuth() {
const [user, setUser] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const insforge = createBrowserInsForgeClient()
insforge.auth.getCurrentSession().then(({ data }) => {
setUser(data?.session?.user ?? null)
setLoading(false)
}).catch(() => {
setLoading(false)
})
}, [])
return { user, loading }
}
// Dashboard 用:沒登入就跳 login
export function useRequireAuth() {
const router = useRouter()
const { user, loading } = useAuth()
useEffect(() => {
if (!loading && !user) {
router.replace('/login')
}
}, [loading, user, router])
return { user, loading: loading || !user }
}
// Auth 頁面用:已登入就跳 dashboard
export function useRedirectIfAuthed() {
const router = useRouter()
const { user, loading } = useAuth()
useEffect(() => {
if (!loading && user) {
router.replace('/dashboard')
}
}, [loading, user, router])
return { user, loading }
}
Step 3:建立 InsForge Client
// lib/insforge-browser.ts — 瀏覽器端 singleton
import { createClient } from '@insforge/sdk'
let client: ReturnType<typeof createClient> | null = null
export function createBrowserInsForgeClient() {
if (!client) {
client = createClient({
baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL!,
anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY!,
})
}
return client
}
// lib/insforge-server.ts — Server 端(給 Hono API 用)
import { createClient } from '@insforge/sdk'
export function createServerInsForgeClient(request?: Request) {
return createClient({
baseUrl: process.env.INSFORGE_URL!,
anonKey: process.env.INSFORGE_ANON_KEY!,
global: {
headers: request ? Object.fromEntries(request.headers) : {},
},
})
}
- 瀏覽器端用
NEXT_PUBLIC_ 前綴的環境變數。沒加這個前綴的話,Next.js 不會把變數打包進 client bundle,你會拿到 undefined 然後 Failed to fetch。
- Server 端用不帶前綴的版本,避免在 client bundle 洩漏 key(雖然 anon key 本身是公開的,但養成好習慣)。
Step 4:刪除 Next.js Middleware
重要:不要建立 middleware.ts。如果已經有了,刪掉它。
Next.js 會自動偵測專案根目錄的 middleware.ts,就算裡面只有註解沒有 export function,也會報錯。直接刪除這個檔案。
Step 5:保護 Dashboard 頁面
// app/dashboard/layout.tsx
'use client'
import { useRequireAuth } from '@/hooks/use-auth'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { loading } = useRequireAuth()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p>Loading...</p>
</div>
)
}
return (
<div>
{/* 你的 sidebar / topbar / main content */}
{children}
</div>
)
}
Step 6:Auth 頁面自動重導
// app/(auth)/layout.tsx
'use client'
import { useRedirectIfAuthed } from '@/hooks/use-auth'
export default function AuthLayout({ children }: { children: React.ReactNode }) {
const { loading, user } = useRedirectIfAuthed()
if (loading || user) {
return (
<div className="min-h-screen flex items-center justify-center">
<p>Loading...</p>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center">
{children}
</div>
)
}
Step 7:實作登入頁面
// app/(auth)/login/page.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createBrowserInsForgeClient } from '@/lib/insforge-browser'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
const insforge = createBrowserInsForgeClient()
const { error: authError } = await insforge.auth.signInWithPassword({
email,
password,
})
setLoading(false)
if (authError) {
setError(authError.message ?? 'Sign in failed')
return
}
router.push('/dashboard')
router.refresh()
}
return (
<form onSubmit={handleSubmit}>
{error && <p>{error}</p>}
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
)
}
Step 8:實作註冊頁面(含 OTP)
// app/(auth)/register/page.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createBrowserInsForgeClient } from '@/lib/insforge-browser'
export default function RegisterPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [name, setName] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [needsVerification, setNeedsVerification] = useState(false)
const [otp, setOtp] = useState('')
async function handleSignUp(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
const insforge = createBrowserInsForgeClient()
const { data, error: authError } = await insforge.auth.signUp({ email, password, name })
setLoading(false)
if (authError) {
setError(authError.message ?? 'Registration failed')
return
}
// InsForge 可能要求 email 驗證
if (data?.requireEmailVerification) {
setNeedsVerification(true) // 切換到 OTP 輸入畫面,不要跳頁
return
}
router.push('/dashboard')
router.refresh()
}
async function handleVerify(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
const insforge = createBrowserInsForgeClient()
const { error: verifyError } = await insforge.auth.verifyEmail({ email, otp })
setLoading(false)
if (verifyError) {
setError(verifyError.message ?? 'Verification failed')
return
}
// verifyEmail 成功後自動建立 session,不需要再 signIn
router.push('/dashboard')
router.refresh()
}
if (needsVerification) {
return (
<form onSubmit={handleVerify}>
<p>We sent a 6-digit code to {email}</p>
{error && <p>{error}</p>}
<input
type="text"
inputMode="numeric"
maxLength={6}
value={otp}
onChange={e => setOtp(e.target.value)}
required
/>
<button type="submit" disabled={loading || otp.length < 6}>
{loading ? 'Verifying...' : 'Verify'}
</button>
</form>
)
}
return (
<form onSubmit={handleSignUp}>
{error && <p>{error}</p>}
<input type="text" value={name} onChange={e => setName(e.target.value)} required />
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} />
<button type="submit" disabled={loading}>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
)
}
verifyEmail() 成功後 SDK 自動儲存 session,不需要再呼叫 signInWithPassword()。
- OTP 驗證必須在同一頁完成,不要跳到其他頁面。這是 InsForge SDK 文件明確要求的。
Step 9:Server-Side API 的 Session Auth(Hono)
Dashboard 頁面的 API 呼叫(例如 fetch('/api/v1/api-keys'))是同源的,瀏覽器會帶上你 domain 的 cookie。但 InsForge 的 session cookie 在 InsForge domain 上。所以 Hono middleware 不能用 SDK 的 getCurrentSession(),要直接打 InsForge 的 refresh endpoint。
// src/middleware/session-auth.ts
import { createMiddleware } from 'hono/factory'
export const sessionAuthMiddleware = createMiddleware(async (c, next) => {
try {
const cookieHeader = c.req.header('cookie')
if (!cookieHeader) {
return c.json({ error: 'Unauthorized' }, 401)
}
// 直接打 InsForge 的 refresh endpoint,轉發 cookie
const res = await fetch(`${process.env.INSFORGE_URL}/api/auth/refresh`, {
method: 'POST',
headers: {
'Cookie': cookieHeader,
'X-Anon-Key': process.env.INSFORGE_ANON_KEY!,
},
})
if (!res.ok) {
return c.json({ error: 'Unauthorized' }, 401)
}
const data = await res.json()
const user = data.user ?? null
if (!user) {
return c.json({ error: 'Unauthorized' }, 401)
}
c.set('userId', user.id)
await next()
} catch {
return c.json({ error: 'Unauthorized' }, 401)
}
})
Step 10:Dual Auth Middleware(同時支援 Cookie 和 API Key)
// src/middleware/dual-auth.ts
import { createMiddleware } from 'hono/factory'
import { apiKeyAuthMiddleware } from './auth'
import { sessionAuthMiddleware } from './session-auth'
export const dualAuthMiddleware = createMiddleware(async (c, next) => {
const authHeader = c.req.header('Authorization')
if (authHeader?.startsWith('Bearer ')) {
return apiKeyAuthMiddleware(c, next)
}
return sessionAuthMiddleware(c, next)
})
Step 11:登出與密碼變更
// 登出
const insforge = createBrowserInsForgeClient()
await insforge.auth.signOut()
router.push('/login')
router.refresh()
// 改密碼(不需要舊密碼,InsForge 透過 session 驗證)
const { error } = await insforge.auth.resetPassword({ newPassword })
5. 快速開始
前提條件
實作步驟
-
安裝依賴
npm install @insforge/sdk
# 如果需要 API 路由
npm install hono
-
設定環境變數
INSFORGE_URL=https://your-project.region.insforge.app
INSFORGE_ANON_KEY=ik_your_key
NEXT_PUBLIC_INSFORGE_URL=https://your-project.region.insforge.app
NEXT_PUBLIC_INSFORGE_ANON_KEY=ik_your_key
-
建立 client files:lib/insforge-browser.ts 和 lib/insforge-server.ts(見 Step 3)
-
建立 auth hooks:hooks/use-auth.ts(見 Step 2)
-
刪除 middleware.ts(如果有的話)
-
套用 auth guard:
- Dashboard layout 用
useRequireAuth()
- Auth layout 用
useRedirectIfAuthed()
-
實作登入/註冊頁面
-
測試驗證
6. 不同技術棧的調整方式
如果你的技術棧跟上面不完全一樣,這邊整理了需要調整的部分。
高度通用的部分(直接用)
useAuth / useRequireAuth / useRedirectIfAuthed hooks 的邏輯
- InsForge browser client singleton pattern
- 登入/註冊/OTP 的呼叫方式
- 「不要用 Next.js middleware」這個結論
需要客製化的部分
| 部分 | 原實作 | 新專案需要調整的地方 |
|---|
| API 路由 session auth | Hono middleware + fetch refresh | 換成你用的 API 框架(Express, Fastify 等)的 middleware 格式 |
| Dual auth | Hono createMiddleware | 改成對應框架的 middleware |
| UI 元件 | shadcn/ui + Tailwind | 換成你的 component library |
| Router | Next.js useRouter | 換成你的路由方案 |
技術棧替換建議
- 如果你用 Express 而非 Hono:session auth middleware 的邏輯一樣(fetch InsForge refresh),只是包裝成
(req, res, next) => {} 格式
- 如果你用 Remix 而非 Next.js:同樣的問題存在。不要在 loader 裡用
getCurrentSession(),改用 client-side auth guard
- 如果你用 Nuxt (Vue):把 React hooks 改成 Vue composables(
useAuth → const { user, loading } = useAuth()),邏輯完全一樣
- 如果 InsForge 未來出了 SSR 套件:類似
@supabase/ssr 的東西,就能切回 server-side auth。到時候可以重新加 middleware.ts
這套架構跑起來比在 middleware 裡掙扎穩定得多。客觀來說,client-side auth 的唯一缺點是未登入用戶會短暫看到頁面骨架再被 redirect,但實際的資料安全性跟 server-side auth 是一樣的,因為 API 層本身有獨立的驗證。如果你也在用 InsForge 做 Next.js 的 auth,這篇應該能幫你跳過我踩過的那些坑。
© 2026 GEEKAZ. All Rights Reserved.