주제: 인증/인가(OAuth2/OIDC, JWT/세션, RBAC/ABAC, 토큰 로테이션) 
- 브라우저는 BFF + 서버 세션 쿠키(HTTPOnly, Secure), API 클라이언트/모바일은 JWT Bearer로 분리
- OIDC Authorization Code + PKCE 고정, state/nonce필수
- 액세스 토큰 짧게(5–10분), 리프레시 토큰 회전형(Reuse Detection) + jti/디바이스 바인딩
- CSRF: 쿠키 기반 쓰기 요청은 더블 서브밋 토큰 or SameSite=Strict/Lax + 커스텀 헤더
- RBAC + 필요한 곳에 ABAC(리소스 소유·조직·속성), 멀티테넌시는 org_id를 1급 시민으로
- 키 로테이션: RSA/ECDSA 키에 kid부여, JWKS 공개, 과거 키 보관(검증용)
- 2FA(TOTP) + WebAuthn(선택), 로그인 시도 레이트리밋/브루트포스 방어
- 감사 로그: 로그인/권한 변경/토큰 재발급/강제 로그아웃
1) 아키텍처 결정: BFF vs. 순수 토큰
- 권장: Next.js BFF(Route Handlers) ↔ FastAPI
- 브라우저 → 서버 세션 쿠키(HTTPOnly)로 SSR/ISR에 유리
- 모바일·서버투서버 → JWT Bearer
 
- 장점: 토큰이 프론트 JS에 노출되지 않음(XSS에 강함), CSRF만 잘 처리하면 OK
[Browser]
   ↕ (cookie, csrf)
[Next.js (BFF)]
   ↕ (service token / mTLS)
[FastAPI]
   ↔ Redis/Postgres (세션/권한)
   ↔ OIDC Provider (로그인)
2) OIDC 플로우(권장 설정)
- Authorization Code + PKCE (공개클라이언트)
- 쿠키:
- 세션: Secure; HttpOnly; SameSite=Lax(일반)
- OIDC 중간 상태(state/nonce) 쿠키: SameSite=None; Secure
 
- 세션: 
- 리다이렉트 도메인·경로 고정(오픈 리다이렉트 방지)
Next.js 콜백(Route Handler) 스케치
// app/api/auth/callback/route.ts
import { NextResponse } from 'next/server';
import { exchangeCodeForTokens, verifyIdToken } from '@/lib/oidc';
export async function GET(req: Request) {
  const url = new URL(req.url);
  const code = url.searchParams.get('code')!;
  const state = url.searchParams.get('state')!;
  // 1) state 검증
  await verifyStateFromCookie(state);
  // 2) 토큰 교환 + ID 토큰 검증(nonce 포함)
  const { id_token, refresh_token, access_token } = await exchangeCodeForTokens(code);
  const claims = await verifyIdToken(id_token);
  // 3) 서버 세션 발급(쿠키)
  const session = await createServerSession({ sub: claims.sub, org_id: claims.org_id });
  const res = NextResponse.redirect(new URL('/', req.url));
  res.cookies.set('sid', session.id, {
    httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60*60*24*7
  });
  // 리프레시 토큰은 서버 저장(또는 암호화해 쿠키 분리)
  await storeRefreshToken(session.id, refresh_token);
  return res;
}
3) 토큰 전략: 짧은 Access + 회전 Refresh
액세스 토큰(JWT)
- 만료(Exp) 5–10분, aud,iss,sub,org_id,scope,jti포함
- 서버 간(Next BFF→FastAPI) 호출에 사용(헤더)
리프레시 토큰(서버 저장 or 암호화)
- 회전(Rotation): 매 재발급마다 새 RT 발급, 이전 RT가 재사용되면 전부 폐기(Reuse Detection)
- 디바이스 바인딩: RT 메타에 UA/IP 해시, 세션ID 저장
FastAPI 리프레시 엔드포인트(요지)
# app/auth.py
from fastapi import APIRouter, Depends, HTTPException, Response
from datetime import datetime, timedelta
import secrets
rt_store = ... # Redis/DB
@router.post("/auth/refresh")
async def refresh(resp: Response, session=Depends(get_session)):
    prev = await rt_store.get(session.id)  # 이전 RT 해시/상태
    body = await read_refresh_request()    # 쿠키/헤더에서 RT 추출(서버 저장 권장)
    if not verify_rt(prev, body):
        # Reuse Detection: 세션 전부 폐기
        await revoke_all(session.user_id)
        raise HTTPException(401, "refresh reuse detected")
    new_at = mint_access_token(sub=session.user_id, org_id=session.org_id, ttl=600)
    new_rt = secrets.token_urlsafe(64)
    await rt_store.set(session.id, hash(new_rt), ex=60*60*24*30)
    set_rotation_cookie(resp, new_rt)
    return {"access_token": new_at, "token_type": "Bearer", "expires_in": 600}
4) CSRF 방어(쿠키 세션일 때 필수)
- 더블 서브밋: 쿠키(csrf) + 헤더(X-CSRF-Token) 값 동일성 검증
- SameSite=Lax로 기본 방어, 크로스사이트 POST 필요시 전용 엔드포인트 + 토큰 검증
// Next.js: 쓰기 요청 시 헤더에 CSRF 첨부
await fetch('/api/orders', {
  method: 'POST',
  headers: { 'X-CSRF-Token': getCsrfFromCookie() },
  body: JSON.stringify(payload),
});
# FastAPI 미들웨어
@app.middleware("http")
async def verify_csrf(request, call_next):
    if request.method in ("POST","PUT","PATCH","DELETE"):
        c = request.cookies.get("csrf")
        h = request.headers.get("X-CSRF-Token")
        if not c or not h or c != h:
            raise HTTPException(403, "CSRF")
    return await call_next(request)
5) 권한 모델: RBAC + ABAC
- RBAC: role in {"owner","admin","editor","viewer"}
- ABAC: 요청 리소스의 org_id,owner_id,labels등 속성 평가
- 멀티테넌시: 모든 데이터 키에 org_id포함, DB는 RLS 고려
FastAPI 의사코드
def guard(*, any_of_roles=None, require_org_match=False):
    async def dep(user=Depends(get_user), resource=Depends(get_resource)):
        if any_of_roles and user.role not in any_of_roles:
            raise HTTPException(403)
        if require_org_match and user.org_id != resource.org_id:
            raise HTTPException(403)
        return user
    return dep
@app.delete("/projects/{id}")
async def delete_project(project=Depends(load_project),
                         user=Depends(guard(any_of_roles={"owner","admin"}, require_org_match=True))):
    ...
6) 세션 관리(장치별 제어)
- 세션 저장소: Redis Hash(TTL), 필드: user_id,org_id,ua_hash,ip_hash,last_seen,revoked
- 기능: 모든 기기 로그아웃, 특정 기기만 로그아웃, 동시 세션 제한
# 세션 검증(미들웨어)
sid = request.cookies.get("sid")
session = await redis.hgetall(f"sid:{sid}")
if not session or session.get("revoked") == "1":
    raise HTTPException(401)
await redis.hset(f"sid:{sid}", mapping={"last_seen": int(time.time())})
7) 2FA/TOTP & WebAuthn(선택)
- TOTP: pyotp로 시크릿 발급 → QR 등록 → 로그인 시 6자리 검증
- 백업 코드 발급(일회용 10개)
- WebAuthn/Passkey: 보안 키·플랫폼 인증자로 피싱 저항성 ↑
8) 로그인 보안 & 속도제한
- 아이디 단서 주지 않기(“이메일 또는 비밀번호가 올바르지 않습니다.”)
- 레이트리밋: IP·계정 키로 5분 10회 등
- 브루트포스 감지: 실패 n회 → 캡차/지연
await rate_limit(request, key_prefix=f"login:{username}", limit=10, window_sec=300)
9) 키 로테이션 & JWKS
- 서명키: RSA-256 또는 ES256, kid부여
- 현재키(active) + 과거키(n-1, n-2) 보관, JWKS 엔드포인트 제공
- 토큰 헤더의 kid로 검증 키 선택
# JWKS 예시
@app.get("/.well-known/jwks.json")
async def jwks():
    return {"keys": [public_jwk_active, public_jwk_prev]}
10) 보안 헤더 & 쿠키 규칙
- Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
- Content-Security-Policy최소 설정(스크립트/이미지 도메인 화이트리스트)
- 쿠키는 항상 Secure; HttpOnly (읽기 필요하면 별도 non-HTTPOnly 쿠키 분리)
- 세션 고정 방지: 로그인·권한상승 시 새 sid 재발급
11) 감사 로그(누가/언제/무엇을)
- 이벤트: 로그인 성공/실패, 비밀번호 변경, 역할 변경, 토큰 재발급, 세션 종료
- 필드: user_id,org_id,ip,ua_hash,result,reason,trace_id
- 불변 스토리지(append-only) + 보존정책
12) “복붙” 스니펫 모음
A. FastAPI: JWT 서명/검증(키 로테이션)
import jwt, time
from typing import Dict
ACTIVE_KID = "2025-10-01"
PRIVATE_KEYS: Dict[str, str] = {...}  # PEM
PUBLIC_KEYS: Dict[str, str] = {...}   # PEM
def mint_access_token(sub: str, org_id: str, ttl=600):
    now = int(time.time())
    payload = {"iss":"https://auth.example.com","sub":sub,"org_id":org_id,"iat":now,"exp":now+ttl,"jti":uuid4().hex}
    return jwt.encode(payload, PRIVATE_KEYS[ACTIVE_KID], algorithm="RS256", headers={"kid": ACTIVE_KID})
def verify_jwt(token: str):
    headers = jwt.get_unverified_header(token)
    kid = headers["kid"]
    pub = PUBLIC_KEYS[kid]
    return jwt.decode(token, pub, algorithms=["RS256"], audience=None, options={"require":["exp","iat","sub"]})
B. Next.js: 페이지 보호(미들웨어)
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
  const sid = req.cookies.get('sid')?.value;
  if (!sid && req.nextUrl.pathname.startsWith('/app')) {
    const url = req.nextUrl.clone(); url.pathname = '/login';
    return NextResponse.redirect(url);
  }
  return NextResponse.next();
}
C. 더블 서브밋 CSRF 생성
// layout.tsx
import { cookies } from 'next/headers';
export default function Layout({ children }) {
  const csrf = crypto.randomUUID();
  cookies().set('csrf', csrf, { httpOnly: false, sameSite: 'lax', secure: true });
  return <html><body data-csrf={csrf}>{children}</body></html>;
}
13) 2주 액션 플랜(템플릿)
- OIDC Code+PKCE 연결, state/nonce·콜백 도메인 고정
- BFF 세션 쿠키 도입(HTTPOnly, Secure, SameSite=Lax) + CSRF 미들웨어
- 액세스 10분/리프레시 회전형 + Reuse Detection 구현
- RBAC 기본 + ABAC(조직·소유자 매칭) 가드 의존성 적용
- 로그인 레이트리밋 + 감사 로그 파이프라인
- JWKS 엔드포인트와 키 로테이션 절차 문서화/크론 준비
- 고위험 액션에 2FA 옵션 추가(비밀번호 변경·권한상승)
마무리
- 브라우저 세션은 BFF+쿠키, API는 JWT — 이 이원화가 운영을 단순화하고 보안을 끌어올립니다.
- 토큰 회전·키 로테·감사 로그를 초기에 “규칙”으로 박아두면, 스케일 커질수록 안정성이 좋아집니다.
다음은 **6편. API 게이트웨이·레이트리밋·엣지(BFF 패턴 포함)**로 갑니다.