인증 서버 기본 요소
이 가이드는 TanStack Start에서 인증을 구축하기 위한 서버 측 프리미티브를 다룹니다: 세션 쿠키, 세션 조회, OAuth, 비밀번호 재설정 강화, CSRF, 그리고 rate limiting입니다. 이 문서는 라우팅 측 가이드(_authenticated 레이아웃, beforeLoad, 리디렉션, RBAC)와 함께 사용됩니다.
Clerk 또는 WorkOS 같은 관리형 솔루션을 사용할 수 있다면, 그것을 우선 권장합니다. 이들 서비스가 이 가이드에서 설명하는 대부분을 처리합니다. 직접 구현하는 경우에만 계속 읽으세요.
데이터를 먼저 보호합니다
인증에는 데이터/API 경계와 라우트/UI 계층이 있습니다. 데이터 경계가 보안 경계입니다. 비공개 데이터를 읽거나 쓰는 모든 server function, server route, 또는 API endpoint는 데이터를 반환하거나 상태를 변경하기 전에 요청을 승인해야 합니다.
- 데이터/API 경계 (이 가이드): session cookie를 발급하고 검증하며, OAuth code를 교환하고, password를 해시 및 검증하고, credential endpoint를 rate-limit하며, user enumeration을 방지하고, 비공개 데이터 액세스를 승인합니다.
- 라우트/UI 계층 (
router-core/auth-and-guards): 로그인하지 않은 사용자를 사용할 수 없는 화면에서 리디렉션하고, role/permission에 따라 UI를 제한하며, 로그인 폼을 표시하고, 어차피 실패할 요청을 시작하지 않도록 합니다.
라우트 가드는 데이터 권한 부여 경계가 아닙니다. Server functions와 server routes는 API endpoint이며, 이를 호출하는 route와는 독립적으로 접근할 수 있습니다. 인증은 비공개 데이터를 다루는 endpoint의 handler 또는 middleware에서 강제되어야 합니다.
beforeLoad는 라우트 UX용입니다.
세션 쿠키
기본 세션 저장소는 HTTP-only cookie입니다. 쿠키에는 다음이 들어갈 수 있습니다:
- 서버가 database에서 조회하는 불투명한 session ID(권장 - 취소하기 쉽습니다).
- 세션 payload 자체를 담는 서명/암호화된 token(무상태이지만, 폐기가 더 어렵습니다).
어느 쪽을 선택하든 cookie 플래그가 중요합니다:
// src/server/session.ts
import {
getRequestHeader,
setResponseHeader,
} from '@tanstack/react-start/server'
const SESSION_COOKIE = '__Host-session'
const ONE_DAY = 60 * 60 * 24
export function setSessionCookie(token: string) {
setResponseHeader(
'Set-Cookie',
[
`${SESSION_COOKIE}=${token}`,
`HttpOnly`,
`Secure`,
`SameSite=Lax`,
`Path=/`,
`Max-Age=${ONE_DAY}`,
].join('; '),
)
}
export function clearSessionCookie() {
setResponseHeader(
'Set-Cookie',
`${SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`,
)
}
export function readSessionToken(): string | null {
const header = getRequestHeader('cookie')
if (!header) return null
for (const part of header.split(/;\s*/)) {
// Split only on the FIRST '=' — signed/base64 values often contain '='.
const eq = part.indexOf('=')
if (eq === -1) continue
if (part.slice(0, eq) === SESSION_COOKIE) return part.slice(eq + 1)
}
return null
}
| Flag | 이유 |
|---|---|
HttpOnly | JavaScript는 cookie를 읽을 수 없습니다. XSS 버그가 session을 유출할 수 없습니다. |
Secure | HTTPS 전용입니다. __Host- 접두사를 사용할 때 필요합니다. |
SameSite=Lax | 최상위 navigation에서 전송됩니다. POST에 대한 대부분의 cross-site CSRF를 차단합니다. cross-site GET navigation 손실이 허용되는 더 높은 위험의 앱에서는 Strict를 사용하십시오. |
__Host- prefix | 쿠키를 정확한 origin에 바인딩합니다. Domain attribute, Path=/, Secure가 필요하지 않습니다. subdomain-takeover session fixation을 방어합니다. |
Path=/ | __Host-에 의해 요구됩니다. |
Max-Age | 수명이 제한됩니다. 서버 측 회전과 함께 사용합니다. |
세션 조회를 미들웨어로 처리합니다
모든 보호된 handler가 타입이 지정된 session을 보도록 middleware에서 session 로딩을 중앙화합니다:
// src/server/auth-middleware.ts
import { createMiddleware } from '@tanstack/react-start'
import { readSessionToken } from './session'
export const authMiddleware = createMiddleware({ type: 'function' }).server(
async ({ next }) => {
const token = readSessionToken()
const session = token ? await db.sessions.findValid(token) : null
if (!session) throw new Error('Unauthorized')
return next({ context: { session } })
},
)
모든 보호된 server function에 첨부합니다:
import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'
export const getMyOrders = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.handler(async ({ context }) => {
return db.orders.findMany({ where: { userId: context.session.userId } })
})
로그인
// src/server/login.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { setSessionCookie } from './session'
export const login = createServerFn({ method: 'POST' })
.validator(z.object({ email: z.string().email(), password: z.string() }))
.handler(async ({ data }) => {
const user = await db.users.findByEmail(data.email)
// Always run verifyPasswordHash — even when the user doesn't exist —
// so the user-not-found branch takes the same time as wrong-password.
// DUMMY_PASSWORD_HASH is a hash of any throwaway password computed once
// at startup with the same algorithm/cost as real password hashes.
const hashToCheck = user?.passwordHash ?? DUMMY_PASSWORD_HASH
const passwordMatches = await verifyPasswordHash(hashToCheck, data.password)
const ok = user != null && passwordMatches
if (!ok) throw new Error('Invalid email or password')
// Rotate: destroy any existing session, then issue fresh.
await db.sessions.revokeAllForUser(user.id)
const token = await db.sessions.create({ userId: user.id })
setSessionCookie(token)
return { ok: true }
})
Invalid email or password 메시지는 "사용자를 찾을 수 없음"과 "비밀번호가 틀림"에서 동일합니다. 위의 더미 해시 기법은 timing도 동일하게 만듭니다: 이것이 없으면 사용자 없음 분기는 즉시 반환되지만, 비밀번호 오류 분기는 해시 비교에 약 100ms를 소비하여 계정 존재 여부를 wire로 누설합니다.
로그아웃
import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'
import { clearSessionCookie } from '~/server/session'
export const logout = createServerFn({ method: 'POST' })
.middleware([authMiddleware])
.handler(async ({ context }) => {
await db.sessions.revoke(context.session.id)
clearSessionCookie()
return { ok: true }
})
OAuth: 상태 + PKCE
OAuth authorization-code flow의 경우:
- 일회용 랜덤
state매개변수를 생성합니다. callback에서 CSRF를 방지합니다. - PKCE
code_verifier/code_challenge쌍을 생성합니다. authorization-code interception을 방어합니다. - 이 정확한 시도에 키가 지정된 짧게 유지되는 signed cookie에 둘 다 저장합니다.
// src/server/oauth.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
import { setResponseHeader } from '@tanstack/react-start/server'
import crypto from 'node:crypto'
const OAUTH_STATE_COOKIE = '__Host-oauth'
function base64url(buf: Buffer) {
return buf
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
}
export const startOAuth = createServerFn({ method: 'GET' }).handler(
async () => {
const state = base64url(crypto.randomBytes(32))
const verifier = base64url(crypto.randomBytes(32))
const challenge = base64url(
crypto.createHash('sha256').update(verifier).digest(),
)
setResponseHeader(
'Set-Cookie',
`${OAUTH_STATE_COOKIE}=${signed({ state, verifier })}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`,
)
throw redirect({
href:
`https://provider.example/authorize` +
`?response_type=code` +
`&client_id=${process.env.OAUTH_CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(process.env.OAUTH_REDIRECT_URI!)}` +
`&state=${state}` +
`&code_challenge=${challenge}` +
`&code_challenge_method=S256`,
})
},
)
callback handler에서:
- cookie를 읽고, 서명을 검증하고,
state+verifier를 추출합니다. - cookie-state를
statequery param과 비교합니다. 일치하지 않으면 중단합니다. - authorization code를 access token으로 교환하며,
code_verifier도 함께 보냅니다. - user profile을 가져오고, 로컬 user record를 찾아/생성하고, session을 발급합니다.
- OAuth cookie를 지웁니다.
이 검사 중 하나라도 실패하면, 요청은 startOAuth에서 시작되지 않은 것이므로 거부해야 합니다.
비밀번호 재설정: 사용자 열거 방지
reset endpoint는 특정 email이 등록되어 있는지 호출자에게 알려주면 안 됩니다. 200과 404를 다르게 반환하거나, 심지어 문구를 다르게 하는 것만으로도 endpoint에 접근할 수 있는 누구에게나 사용자 존재 여부를 누설합니다.
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
export const requestPasswordReset = createServerFn({ method: 'POST' })
.validator(z.object({ email: z.string().email() }))
.handler(async ({ data }) => {
const user = await db.users.findByEmail(data.email)
if (user) {
const token = await db.passwordResets.issue(user.id)
await sendResetEmail(user.email, token)
}
// Same response, same body, regardless of existence.
return { ok: true }
})
하지 마십시오:
- 존재하면 200, 없으면 404를 반환하지 마십시오.
- 메시지를 달리하지 마십시오("링크를 보내드렸습니다" vs "계정을 찾을 수 없습니다").
- 사용자가 없을 때 작업을 건너뛰지 마십시오(타이밍 누설 - wire에서 측정할 수 있습니다).
non-GET RPC의 CSRF
세션 cookie의 SameSite=Lax는 POST/PUT/DELETE에 대한 대부분의 cross-site CSRF를 차단합니다. 두 가지 경우에는 명시적인 방어가 필요합니다:
- GET-that-mutates — 절대 안 됩니다. 모든 mutation에는 POST/PUT/DELETE를 사용하십시오.
- 형제 subdomain에서 오는 POST —
SameSite=Lax는 이를 차단하지 않습니다.Originheader가 앱과 일치하는지 검증하십시오.
import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
export const csrfMiddleware = createMiddleware().server(async ({ next }) => {
const request = getRequest()
if (request.method !== 'GET' && request.method !== 'HEAD') {
const origin = request.headers.get('origin')
// Compare the FULL origin (scheme + host + port) — host alone lets
// http://example.com pass a check meant for https://example.com.
if (!origin || new URL(origin).origin !== process.env.APP_ORIGIN) {
throw new Error('Origin check failed')
}
}
return next()
})
이것을 src/start.ts 전역 requestMiddleware에 첨부하여 server routes와 SSR을 포함한 모든 non-GET 요청에서 실행되게 하십시오.
인증 endpoint의 rate limiting
rate limiting이 없는 login endpoint는 credential stuffing의 대상입니다. sliding window 또는 token bucket으로 IP별(그리고 사용자를 식별할 수 있으면 account별로도) 제한하십시오.
import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
function rateLimitMiddleware(opts: {
key: string
max: number
windowMs: number
}) {
return createMiddleware().server(async ({ next }) => {
const request = getRequest()
const ip =
request.headers.get('cf-connecting-ip') ??
request.headers.get('x-forwarded-for')?.split(',')[0] ??
'unknown'
const allowed = await rateLimiter.consume(
`rl:${opts.key}:${ip}`,
opts.max,
opts.windowMs,
)
if (!allowed) throw new Error('Too many requests')
return next()
})
}
export const login = createServerFn({ method: 'POST' }).middleware([
rateLimitMiddleware({ key: 'login', max: 5, windowMs: 60_000 }),
])
// ...
세션 회전
사용자의 권한이 바뀔 때마다 - login, logout, password change, role grant - 오래된 session을 파기하고 새 session을 발급하십시오. 이는 권한 변경 전에 공격자가 피해자의 browser에 자기 session ID를 심어 두는 session fixation 공격을 무력화합니다.
// On login: revoke any pre-login session, create fresh.
await db.sessions.revokeAllForUser(user.id)
const token = await db.sessions.create({ userId: user.id })
setSessionCookie(token)
// On password change / role grant:
await db.sessions.revokeAllForUser(user.id)
const token = await db.sessions.create({ userId: user.id })
setSessionCookie(token)
Module scope가 아니라 요청마다 Cookie와 Env를 읽으십시오
Module-scope reads are wrong on two axes:
- 보안: 클라이언트 bundle에 인라인될 수 있습니다.
- edge runtime에서의 정확성: Cloudflare Workers(및 기타)는 요청 시점에 env를 주입합니다. module-level reads는 요청이 존재하기 전에 실행되며, 서버에서도
undefined로 평가됩니다.
// ❌ Wrong
const SESSION_SECRET = process.env.SESSION_SECRET
export function signSession(payload) {
return sign(payload, SESSION_SECRET)
}
// ✅ Right
export function signSession(payload) {
return sign(payload, process.env.SESSION_SECRET)
}
자세한 규칙은 실행 모델: 모듈 수준 process.env 읽기를 참고하십시오.
관련 자료
- 인증 개요 — 파트너 솔루션, OSS 라이브러리, 그리고 직접 구현 중에서 선택하는 방법입니다.
- 인증된 라우트(Router) — 라우팅 측 가이드입니다.
- 서버 함수 — 인증이 들어 있는 RPC 기본 요소입니다.
- Middleware —
authMiddleware를 구성합니다. - OWASP 치트 시트 — 인증, 세션 관리, CSRF.
- MDN — Set-Cookie.