diff --git a/.cursor/rules/frontend/auth.mdc b/.cursor/rules/frontend/auth.mdc
index 9b21c58f..214cb01e 100644
--- a/.cursor/rules/frontend/auth.mdc
+++ b/.cursor/rules/frontend/auth.mdc
@@ -25,3 +25,9 @@ See @apps/docu/content/docs/architecture/authentication.mdx for full architectur
- Core calls Fastify `POST /auth/session/refresh` directly on 401 (no BFF proxy)
- `onTokensRefreshed` → `updateAuthTokens` → `POST /api/auth/update-tokens` persists new tokens in cookie
- Proxy (`proxy.ts`) refreshes on navigation; core refreshes on client-side 401 (e.g. `useUser`)
+
+## Auth Protection (proxy.ts)
+
+- Proxy (`proxy.ts`) is the single source of truth for route-level auth: unauthenticated users → `/auth/login`; authenticated on `/auth/login` → `/`
+- **Never** add `getAuthStatus()` + `redirect()` in layouts or pages for auth gating—proxy already enforces this
+- Use `getAuthStatus()` or `getUserInfo()` only when you need user/session data for rendering (e.g. shell, profile), not for redirect logic
diff --git a/.gitleaks.toml b/.gitleaks.toml
index 1f6b3b2d..262cf651 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -28,6 +28,7 @@ paths = [
'''examples/''',
'''docs/''',
'''apps/fastify/scripts/generate-openapi\.ts$''',
+ '''scripts/run-qa\.mjs$''',
]
regexes = [
diff --git a/apps/docu/content/docs/architecture/authentication.mdx b/apps/docu/content/docs/architecture/authentication.mdx
index 42f74e8a..b1980118 100644
--- a/apps/docu/content/docs/architecture/authentication.mdx
+++ b/apps/docu/content/docs/architecture/authentication.mdx
@@ -328,6 +328,8 @@ All endpoints below are served by `apps/fastify`. The API does not set cookies;
**Proxy vs coreClient refresh**: The proxy (`proxy.ts`) runs on navigation before React mounts—it refreshes expired tokens so the user isn’t redirected to login. The core client runs on client-side 401 (e.g. `useUser`)—it calls Fastify `POST /auth/session/refresh` directly, then `onTokensRefreshed` POSTs to `/api/auth/update-tokens` to persist new tokens in the cookie. Both are needed.
+**Route protection**: Proxy handles all auth redirects. Do not duplicate `getAuthStatus()` + redirect in layouts or pages.
+
**Cookies**: Single cookie `api.session` (configurable via `AUTH_COOKIE_NAME` / `NEXT_PUBLIC_AUTH_COOKIE_NAME`) stores JSON `{ token, refreshToken }`. Readable on the client (`httpOnly: false`) so `getAuthToken` can read from `document.cookie`. Cookie `maxAge` is derived from refresh JWT `exp`.
**Core auth modes**: `createClient` supports three modes—(1) **apiKey**: static Bearer, no refresh; (2) **JWT**: `getAuthToken`, `getRefreshToken`, `onTokensRefreshed` required, refresh on 401; (3) **no-auth**: `baseUrl` only.
diff --git a/apps/next/.env.production b/apps/next/.env.production
index 2a22d67e..5182aa0b 100644
--- a/apps/next/.env.production
+++ b/apps/next/.env.production
@@ -5,3 +5,6 @@
# NOTE: This uses a unified/staging URL across environments
# CI/CD validation will fail if this matches the placeholder
NEXT_PUBLIC_API_URL=https://basilic-fastify.vercel.app
+
+# JWT_SECRET must be set via env (Vercel/deployment) - not committed. Use same value as Fastify.
+# For local prod build: export JWT_SECRET='your-32-char-secret' or add to .env.local
diff --git a/apps/next/app/(dashboard)/(news)/page.tsx b/apps/next/app/(dashboard)/(news)/page.tsx
index 8ef971c9..7720262d 100644
--- a/apps/next/app/(dashboard)/(news)/page.tsx
+++ b/apps/next/app/(dashboard)/(news)/page.tsx
@@ -1,6 +1,4 @@
import { getErrorMessage } from '@repo/error/nextjs'
-import { getAuthStatus } from 'lib/auth/auth-utils'
-import { redirect } from 'next/navigation'
import { env } from '@/lib/env'
import { NewsList, type NewsListArticle } from './news-list'
@@ -26,9 +24,6 @@ async function fetchHeadlines() {
}
export default async function Home() {
- const { authenticated } = await getAuthStatus()
- if (!authenticated) redirect('/auth/login')
-
const { articles, error, hasKey } = await fetchHeadlines()
const fallback = !hasKey ? (
diff --git a/apps/next/app/(dashboard)/_dashboard-shell.tsx b/apps/next/app/(dashboard)/_dashboard-shell.tsx
new file mode 100644
index 00000000..4de23012
--- /dev/null
+++ b/apps/next/app/(dashboard)/_dashboard-shell.tsx
@@ -0,0 +1,70 @@
+'use client'
+
+import { Button } from '@repo/ui/components/button'
+import { SidebarInset, SidebarProvider, SidebarTrigger } from '@repo/ui/components/sidebar'
+import { Toaster } from '@repo/ui/components/sonner'
+import { useQueryClient } from '@tanstack/react-query'
+import { AssistantSidebar } from 'components/assistant'
+import { ApiHealthBadge } from 'components/shared/api-health-badge'
+import { AuthBadge } from 'components/shared/auth-badge'
+import { LogOut } from 'lucide-react'
+import { toast } from 'sonner'
+import { authSessionJwtQueryKey, authSessionUserQueryKey } from '@/lib/query-keys'
+
+import { PageTitle } from './page-title'
+import { DashboardSidebar } from './sidebar'
+
+export function DashboardShell({
+ children,
+}: Readonly<{ children: React.ReactNode }>): React.JSX.Element {
+ const queryClient = useQueryClient()
+
+ async function handleSignOut() {
+ const response = await fetch('/auth/logout', { redirect: 'manual' })
+ const isSuccess = response.status >= 200 && response.status < 400
+ if (!isSuccess) {
+ toast.error('Sign out failed. Please try again.')
+ return
+ }
+ queryClient.invalidateQueries({ queryKey: authSessionUserQueryKey })
+ queryClient.invalidateQueries({ queryKey: authSessionJwtQueryKey })
+ window.location.href = '/'
+ }
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/apps/next/app/(dashboard)/layout.tsx b/apps/next/app/(dashboard)/layout.tsx
index a975aa2d..b6aae1d9 100644
--- a/apps/next/app/(dashboard)/layout.tsx
+++ b/apps/next/app/(dashboard)/layout.tsx
@@ -1,52 +1,7 @@
-import { Button } from '@repo/ui/components/button'
+import { DashboardShell } from './_dashboard-shell'
-import { SidebarInset, SidebarProvider, SidebarTrigger } from '@repo/ui/components/sidebar'
-import { Toaster } from '@repo/ui/components/sonner'
-import { AssistantSidebar } from 'components/assistant'
-import { ApiHealthBadge } from 'components/shared/api-health-badge'
-import { AuthBadge } from 'components/shared/auth-badge'
-import { LogOut } from 'lucide-react'
-import Link from 'next/link'
-
-import { PageTitle } from './page-title'
-import { DashboardSidebar } from './sidebar'
-
-export default async function Layout({ children }: Readonly<{ children: React.ReactNode }>) {
- return (
-
-
-
-
-
- )
+export default async function Layout({
+ children,
+}: Readonly<{ children: React.ReactNode }>): Promise {
+ return {children}
}
diff --git a/apps/next/app/(dashboard)/markets/page.tsx b/apps/next/app/(dashboard)/markets/page.tsx
index 797304d7..952f684f 100644
--- a/apps/next/app/(dashboard)/markets/page.tsx
+++ b/apps/next/app/(dashboard)/markets/page.tsx
@@ -1,6 +1,4 @@
import { getErrorMessage } from '@repo/error/nextjs'
-import { getAuthStatus } from 'lib/auth/auth-utils'
-import { redirect } from 'next/navigation'
import type { CoinMarket } from './markets-table'
import { MarketsTable } from './markets-table'
@@ -19,9 +17,6 @@ async function fetchMarkets() {
}
export default async function MarketsPage() {
- const { authenticated } = await getAuthStatus()
- if (!authenticated) redirect('/auth/login')
-
const { coins, error } = await fetchMarkets()
return (
diff --git a/apps/next/app/auth/login/page.tsx b/apps/next/app/auth/login/page.tsx
index b4b631c7..f4f7cb07 100644
--- a/apps/next/app/auth/login/page.tsx
+++ b/apps/next/app/auth/login/page.tsx
@@ -1,10 +1,8 @@
import { ApiHealthBadge } from 'components/shared/api-health-badge'
import { AuthBadge } from 'components/shared/auth-badge'
import { getAuthErrorMessage } from 'lib/auth/auth-error-messages'
-import { getAuthStatus } from 'lib/auth/auth-utils'
import { GalleryVerticalEnd } from 'lucide-react'
import Image from 'next/image'
-import { redirect } from 'next/navigation'
import { LoginActions } from './login-actions'
type LoginPageProps = {
@@ -16,10 +14,6 @@ type LoginPageProps = {
export default async function LoginPage({ searchParams }: LoginPageProps) {
const params = await searchParams
- const { authenticated } = await getAuthStatus()
-
- if (authenticated) redirect('/')
-
const errorParam = params.error || params.message
const errorMessage = getAuthErrorMessage(errorParam)
diff --git a/apps/next/lib/auth/auth-utils.ts b/apps/next/lib/auth/auth-utils.ts
index 7d78fa24..f05cccd3 100644
--- a/apps/next/lib/auth/auth-utils.ts
+++ b/apps/next/lib/auth/auth-utils.ts
@@ -1,7 +1,7 @@
import { z } from 'zod'
import { env } from '@/lib/env'
import { getServerAuthToken } from './auth-server'
-import { decodeJwtToken, isTokenExpired } from './jwt-utils'
+import { isTokenExpired, verifyJwtToken } from './jwt-utils'
const userResponseSchema = z
.object({
@@ -26,7 +26,7 @@ export async function getAuthStatus(): Promise<{
if (!token) return { authenticated: false, userId: null, sessionId: null }
- const decoded = decodeJwtToken({ token })
+ const decoded = await verifyJwtToken({ token, secret: env.JWT_SECRET })
if (!decoded || decoded.typ !== 'access' || !decoded.sub || !decoded.sid)
return { authenticated: false, userId: null, sessionId: null }
diff --git a/apps/next/lib/auth/jwt-utils.ts b/apps/next/lib/auth/jwt-utils.ts
index d2a235e0..c6d2e14a 100644
--- a/apps/next/lib/auth/jwt-utils.ts
+++ b/apps/next/lib/auth/jwt-utils.ts
@@ -1,7 +1,8 @@
-import { decodeJwt } from 'jose'
+import { decodeJwt, jwtVerify } from 'jose'
import type { JwtPayload } from './auth-schemas'
import { jwtPayloadSchema } from './auth-schemas'
+/** Decode without verification. Use only for non-auth cases (e.g. reading exp for cookie maxAge). */
export function decodeJwtToken({ token }: { token: string }): JwtPayload | null {
try {
const decoded = decodeJwt(token)
@@ -12,8 +13,24 @@ export function decodeJwtToken({ token }: { token: string }): JwtPayload | null
}
}
+export async function verifyJwtToken({
+ token,
+ secret,
+}: {
+ token: string
+ secret: string
+}): Promise {
+ try {
+ const { payload } = await jwtVerify(token, new TextEncoder().encode(secret))
+ const parsed = jwtPayloadSchema.safeParse(payload)
+ return parsed.success ? parsed.data : null
+ } catch {
+ return null
+ }
+}
+
export function isTokenExpired({ token }: { token: string }): boolean {
- const decoded = decodeJwtToken({ token })
- if (!decoded?.exp) return true
- return decoded.exp * 1000 <= Date.now()
+ const payload = decodeJwtToken({ token })
+ if (!payload?.exp) return true
+ return payload.exp * 1000 <= Date.now()
}
diff --git a/apps/next/lib/env.ts b/apps/next/lib/env.ts
index c31367d0..e3392fe3 100644
--- a/apps/next/lib/env.ts
+++ b/apps/next/lib/env.ts
@@ -6,6 +6,16 @@ export const env = createEnv({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
ALLOW_TEST: z.enum(['true', 'false']).optional(),
AUTH_COOKIE_NAME: z.string().default('api.session'),
+ JWT_SECRET:
+ process.env.NODE_ENV === 'production'
+ ? z
+ .string()
+ .min(32)
+ .refine(
+ val => val !== 'default-jwt-secret-min-32-chars-for-dev',
+ 'JWT_SECRET must not be the dev default in production',
+ )
+ : z.string().min(32).default('default-jwt-secret-min-32-chars-for-dev'),
NEWSAPI_KEY: z.string().optional(),
ERROR_REPORTING_DSN: z.string().min(1).optional(),
ERROR_REPORTING_ENVIRONMENT: z.string().min(1).optional(),
@@ -26,6 +36,7 @@ export const env = createEnv({
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
ALLOW_TEST: process.env.ALLOW_TEST,
AUTH_COOKIE_NAME: process.env.AUTH_COOKIE_NAME,
+ JWT_SECRET: process.env.JWT_SECRET,
NEWSAPI_KEY: process.env.NEWSAPI_KEY,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
diff --git a/apps/next/lib/query-keys.ts b/apps/next/lib/query-keys.ts
new file mode 100644
index 00000000..19c29e0a
--- /dev/null
+++ b/apps/next/lib/query-keys.ts
@@ -0,0 +1,3 @@
+/** Query keys matching @repo/react auth hooks (useUser, useSession). Used for invalidation only. */
+export const authSessionUserQueryKey = ['auth', 'session', 'user'] as const
+export const authSessionJwtQueryKey = ['auth', 'session', 'jwt'] as const
diff --git a/apps/next/proxy.ts b/apps/next/proxy.ts
index 14ea92b8..811b504f 100644
--- a/apps/next/proxy.ts
+++ b/apps/next/proxy.ts
@@ -1,7 +1,7 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { refreshTokensWithRefreshToken, setAuthCookiesOnResponse } from '@/lib/auth/auth-server'
-import { isTokenExpired } from '@/lib/auth/jwt-utils'
+import { decodeJwtToken, isTokenExpired, verifyJwtToken } from '@/lib/auth/jwt-utils'
import { parseAuthCookie } from '@/lib/auth/parse-auth-cookie'
import { env } from '@/lib/env'
@@ -22,12 +22,15 @@ async function checkAuthStatus(request: NextRequest): Promise {
if (!token) return { status: 'unauthenticated', shouldClearCookies: false }
try {
- const { decodeJwtToken } = await import('@/lib/auth/jwt-utils')
- const jwtDecoded = decodeJwtToken({ token })
- if (jwtDecoded?.typ !== 'access' || !jwtDecoded?.sub || !jwtDecoded?.sid)
+ const payload = decodeJwtToken({ token })
+ if (payload?.typ !== 'access' || !payload?.sub || !payload?.sid)
return { status: 'unauthenticated', shouldClearCookies: true }
- if (!isTokenExpired({ token })) return { status: 'authenticated', shouldClearCookies: false }
+ if (!isTokenExpired({ token })) {
+ const verified = await verifyJwtToken({ token, secret: env.JWT_SECRET })
+ if (verified) return { status: 'authenticated', shouldClearCookies: false }
+ return { status: 'unauthenticated', shouldClearCookies: true }
+ }
if (!refreshToken) return { status: 'unauthenticated', shouldClearCookies: true }
diff --git a/apps/next/scripts/run-e2e-local.mjs b/apps/next/scripts/run-e2e-local.mjs
index 58d297c0..b729d288 100644
--- a/apps/next/scripts/run-e2e-local.mjs
+++ b/apps/next/scripts/run-e2e-local.mjs
@@ -62,9 +62,19 @@ async function main() {
// eslint-disable-next-line turbo/no-undeclared-env-vars -- set by root test:e2e or user
if (!process.env.SKIP_BUILD) {
+ const loadedForBuild = loadEnvTest()
+ const buildEnv = {
+ ...process.env,
+ ...loadedForBuild,
+ JWT_SECRET:
+ loadedForBuild.JWT_SECRET ??
+ process.env.JWT_SECRET ??
+ 'e2e-jwt-secret-min-32-chars-for-tests',
+ }
const build = spawn('pnpm', ['-F', '@repo/next', 'run', 'build:e2e'], {
cwd: repoRoot,
stdio: 'inherit',
+ env: buildEnv,
})
const buildCode = await new Promise(r => build.on('exit', c => r(c ?? 1)))
if (buildCode !== 0) process.exit(buildCode)
diff --git a/scripts/run-qa.mjs b/scripts/run-qa.mjs
index d4ef8d86..4d9e2ceb 100644
--- a/scripts/run-qa.mjs
+++ b/scripts/run-qa.mjs
@@ -11,6 +11,10 @@ import { fileURLToPath } from 'node:url'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const repoRoot = dirname(scriptDir)
+const qaBuildEnv = process.env.JWT_SECRET
+ ? undefined
+ : { JWT_SECRET: 'qa-build-placeholder-min-32-chars-to-pass-validation' }
+
const phases = [
{ name: 'install', cmd: 'pnpm', args: ['i', '--no-frozen-lockfile'] },
{
@@ -19,7 +23,7 @@ const phases = [
args: ['exec', 'turbo', 'run', 'checktypes', '--concurrency=100%'],
},
{ name: 'lint:fix', cmd: 'pnpm', args: ['lint:fix'] },
- { name: 'build', cmd: 'pnpm', args: ['build'] },
+ { name: 'build', cmd: 'pnpm', args: ['build'], env: qaBuildEnv },
{ name: 'test', cmd: 'pnpm', args: ['exec', 'turbo', 'run', 'test', '--concurrency=100%'] },
{ name: 'test:e2e', cmd: 'pnpm', args: ['test:e2e'] },
]
diff --git a/turbo.json b/turbo.json
index 87c2c974..7cf06b6e 100644
--- a/turbo.json
+++ b/turbo.json
@@ -26,6 +26,7 @@
"outputs": [".next/**", "!.next/cache/**", "dist/**", ".react-email/**"],
"env": [
"NODE_ENV",
+ "JWT_SECRET",
"AI_PROVIDER",
"OLLAMA_BASE_URL",
"OPEN_ROUTER_API_KEY",