前端架构核心

前端鉴权体系深度解析

鉴权(Authentication)就像酒店的入住系统:你出示身份证(凭证),前台验证身份(鉴权),给你房卡(Token),之后你用房卡进出房间(访问资源)。

Session

服务端记住你是谁

JWT

自证身份的通行证

OAuth 2.0

借别人的钥匙开门

SSO

一把钥匙开所有门

为什么需要鉴权

没有鉴权,互联网将是一片混乱

身份冒充

任何人可以伪装成任何用户,发送任意请求。没有鉴权,任何人都能以你的名义转账、发消息、修改数据。

100%
无鉴权应用存在此风险

数据泄露

敏感数据(订单、个人信息、财务数据)对所有人可见。2023年全球数据泄露平均损失达445万美元。

$4.45M
2023平均数据泄露损失

权限混乱

普通用户可以访问管理员功能,删除数据、修改配置。没有授权机制,所有用户权限相同。

68%
内部威胁导致的安全事件

认证 Authentication

你是谁?→ 验证身份

  • 用户名 + 密码验证
  • 手机验证码
  • 生物识别(指纹、人脸)
  • 第三方 OAuth 登录

授权 Authorization

你能做什么?→ 分配权限

  • 角色权限控制(RBAC)
  • 资源级别访问控制
  • API 权限范围(Scope)
  • 数据行级权限
核心原理

四大鉴权方案深度拆解

从传统的 Session 到现代的 JWT,从第三方授权 OAuth 2.0 到企业级 SSO,理解每种方案的本质差异。

JWT 认证流程
1
用户登录

发送凭证到服务器

2
生成JWT

签名Header+Payload

3
返回Token

客户端存储Token

4
携带请求

Authorization: Bearer xxx

5
验证签名

无需查库,本地验签

6
返回资源

签名有效,返回数据

JWT 交互式解析器动手试试

JWT Token 结构解析LIVE
eyAiYWxnIjogIkhTMjU2IiwgInR5cCI6ICJKV1QiIH0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IuW8oOS4iSIsInJvbGUiOiJhZG1pbiJ9.SE1BQ1NIQTI1NihleUFpWVd4bklqb2dJa2hUTWpVMkl
Header(头部)
Payload(载荷)
Signature(签名)
算法:HS256
过期时间:2024/6/26 10:40:00
Token 长度:156 字符

Header(头部)

声明 Token 的类型和签名算法。固定为 JWT,常用 HS256 或 RS256。

{
  "alg": "HS256",  // ← 签名算法
  "typ": "JWT"     // ← Token 类型
}

Payload(载荷)

存放用户信息和声明。注意:仅 Base64 编码,未加密,切勿存放敏感数据!

{
  "sub": "user-123",    // ← 用户ID(Subject)
  "name": "张三",       // ← 用户名
  "role": "admin",     // ← 角色
  "iat": 1719312000,   // ← 签发时间
  "exp": 1719398400    // ← 过期时间
}

Signature(签名)

使用密钥对 Header 和 Payload 进行签名。用于验证数据完整性,防篡改。

HMACSHA256(
  base64UrlEncode(header) +
  "." +
  base64UrlEncode(payload),
  secret  // ← 只有服务端知道
)

服务端:生成与验证 JWT

auth/jwt.tstypescript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
import jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET;     // ← 256位随机密钥
const ACCESS_EXPIRES = '15m';              // ← 短期访问令牌
const REFRESH_EXPIRES = '7d';              // ← 长期刷新令牌

interface TokenPayload {
  sub: string;   // 用户ID
  role: string;  // 用户角色
}

// 生成 Token 对
export function generateTokens(user: TokenPayload) {
  const accessToken = jwt.sign(
    {
      sub: user.sub,
      role: user.role,
      type: 'access'
    },
    SECRET,
    { expiresIn: ACCESS_EXPIRES }         // ← 15分钟过期
  );

  const refreshToken = jwt.sign(
    {
      sub: user.sub,
      type: 'refresh'
    },
    SECRET,
    { expiresIn: REFRESH_EXPIRES }        // ← 7天过期
  );

  return { accessToken, refreshToken };   // ← 返回双 Token
}

// 验证 Token
export function verifyToken(token: string): TokenPayload {
  try {
    const decoded = jwt.verify(token, SECRET) as any;
    
    if (decoded.type !== 'access') {      // ← 检查 Token 类型
      throw new Error('Invalid token type');
    }
    
    return decoded;
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      throw new Error('Token expired');   // ← Token 已过期
    }
    throw new Error('Invalid token');     // ← Token 无效
  }
}

客户端:存储与自动刷新

lib/auth-client.tstypescript
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
const API_URL = process.env.NEXT_PUBLIC_API_URL;

// Token 存储策略
const tokenStorage = {
  // Access Token 存内存(最安全,防 XSS)
  _accessToken: null as string | null,
  
  getAccessToken() {
    return this._accessToken;
  },
  
  setAccessToken(token: string) {
    this._accessToken = token;
  },
  
  // Refresh Token 存 httpOnly Cookie(服务端设置)
  // 或 localStorage(次优选择)
};

// 带自动刷新的请求封装
export async function fetchWithAuth(
  url: string, 
  options: RequestInit = {}
) {
  let token = tokenStorage.getAccessToken();
  
  const response = await fetch(`${API_URL}${url}`, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,  // ← 携带 Token
    },
  });
  
  // Token 过期,尝试刷新
  if (response.status === 401) {
    const newToken = await refreshAccessToken();
    
    if (newToken) {
      tokenStorage.setAccessToken(newToken);
      
      // 用新 Token 重试请求
      return fetch(`${API_URL}${url}`, {
        ...options,
        headers: {
          ...options.headers,
          Authorization: `Bearer ${newToken}`,
        },
      });
    }
  }
  
  return response;
}

// 刷新 Access Token
async function refreshAccessToken(): Promise<string | null> {
  const response = await fetch(`${API_URL}/auth/refresh`, {
    method: 'POST',
    credentials: 'include',             // ← 携带 Cookie(refresh token)
  });
  
  if (response.ok) {
    const { accessToken } = await response.json();
    return accessToken;
  }
  
  // 刷新失败,跳转登录
  window.location.href = '/login';
  return null;
}
JWT 安全陷阱
  • 🚫永远不要在 Payload 存密码或敏感信息:JWT 仅是 Base64 编码,任何人可以解码查看内容
  • 🚫不要用 none 算法:某些库允许 { alg: 'none' } 跳过验证,务必在服务端白名单允许的算法
  • 🚫Access Token 生命周期要短:推荐 15-30 分钟,配合 Refresh Token 实现无感刷新
  • 🚫不要存 localStorage(高安全场景):易受 XSS 攻击,推荐存内存变量 + Refresh Token 存 httpOnly Cookie
方案对比

Session vs JWT 全维度对比

特性
Session
JWT
存储位置
服务端(内存/Redis)
客户端(localStorage/Cookie)
服务端状态
✅ 有状态
❌ 无状态
扩展性
需共享Session存储
天然支持分布式
性能
每次请求查询存储
本地验签,无需查库
安全性
Cookie自动防护CSRF
需手动防护XSS/CSRF
Token大小
~20字节(session_id)
~200-800字节
即时失效
✅ 删除Session即可
❌ 需等待过期或黑名单
跨域支持
需配置CORS+Credentials
天然支持
移动端适配
Cookie限制
Header传递,无限制
适用场景
传统Web应用、高安全要求
SPA、移动端、微服务
代码实战

Next.js 全栈鉴权实现

从登录页到受保护路由,从中间件到 API 鉴权,一套完整的生产级实现。

登录页面组件

app/login/page.tsxtsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { signIn } from 'next-auth/react';

export default function LoginPage() {
  const router = useRouter();
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState('');

  // 账号密码登录
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setIsLoading(true);
    setError('');

    const formData = new FormData(e.currentTarget);
    
    const result = await signIn('credentials', {
      email: formData.get('email'),
      password: formData.get('password'),
      redirect: false,                  // ← 不自动跳转
    });

    if (result?.error) {
      setError('邮箱或密码错误');         // ← 显示错误
      setIsLoading(false);
    } else {
      router.push('/dashboard');         // ← 跳转到仪表盘
      router.refresh();                  // ← 刷新服务端组件
    }
  }

  // 第三方登录
  async function handleOAuthLogin(provider: string) {
    setIsLoading(true);
    await signIn(provider, { 
      callbackUrl: '/dashboard'          // ← 登录成功后跳转
    });
  }

  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="w-full max-w-md p-8 bg-white rounded-2xl 
                      border-2 border-[var(--foreground)] 
                      shadow-[8px_8px_0px_0px_var(--foreground)]">
        <h1 className="text-3xl font-['Outfit'] font-extrabold mb-8">
          登录
        </h1>

        {error && (
          <div className="mb-4 p-3 bg-red-50 border-2 border-red-300 
                          rounded-xl text-sm text-red-700">
            {error}
          </div>
        )}

        {/* 第三方登录按钮 */}
        <div className="space-y-3 mb-6">
          <button
            onClick={() => handleOAuthLogin('github')}
            disabled={isLoading}
            className="w-full py-3 bg-[var(--foreground)] text-white 
                       rounded-xl font-bold border-2 border-[var(--foreground)]
                       shadow-[4px_4px_0px_0px_var(--border)]
                       hover:shadow-[2px_2px_0px_0px_var(--border)]
                       hover:translate-x-[2px] hover:translate-y-[2px]
                       transition-all flex items-center justify-center gap-2"
          >
            <GitHubIcon /> 使用 GitHub 登录
          </button>
          
          <button
            onClick={() => handleOAuthLogin('google')}
            disabled={isLoading}
            className="w-full py-3 bg-white text-[var(--foreground)] 
                       rounded-xl font-bold border-2 border-[var(--foreground)]
                       shadow-[4px_4px_0px_0px_var(--foreground)]
                       hover:shadow-[2px_2px_0px_0px_var(--foreground)]
                       hover:translate-x-[2px] hover:translate-y-[2px]
                       transition-all flex items-center justify-center gap-2"
          >
            <GoogleIcon /> 使用 Google 登录
          </button>
        </div>

        <div className="relative my-6">
          <div className="absolute inset-0 flex items-center">
            <div className="w-full border-t-2 border-[var(--border)]" />
          </div>
          <div className="relative flex justify-center">
            <span className="bg-white px-4 text-sm text-[var(--foreground)]/50">
              或使用邮箱登录
            </span>
          </div>
        </div>

        {/* 邮箱密码表单 */}
        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm font-bold mb-2">邮箱</label>
            <input
              name="email"
              type="email"
              required
              className="w-full px-4 py-3 border-2 border-[var(--foreground)] 
                         rounded-xl focus:outline-none focus:border-[var(--accent)]
                         shadow-[2px_2px_0px_0px_var(--border)]"
            />
          </div>
          <div>
            <label className="block text-sm font-bold mb-2">密码</label>
            <input
              name="password"
              type="password"
              required
              className="w-full px-4 py-3 border-2 border-[var(--foreground)] 
                         rounded-xl focus:outline-none focus:border-[var(--accent)]
                         shadow-[2px_2px_0px_0px_var(--border)]"
            />
          </div>
          <button
            type="submit"
            disabled={isLoading}
            className="w-full py-3 bg-[var(--accent)] text-white 
                       rounded-xl font-bold border-2 border-[var(--foreground)]
                       shadow-[4px_4px_0px_0px_var(--foreground)]
                       hover:shadow-[2px_2px_0px_0px_var(--foreground)]
                       hover:translate-x-[2px] hover:translate-y-[2px]
                       transition-all"
          >
            {isLoading ? '登录中...' : '登录'}
          </button>
        </form>
      </div>
    </div>
  );
}

自定义鉴权 Hook

hooks/useAuth.tstypescript
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
'use client';

import { useSession, signOut } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

interface AuthUser {
  id: string;
  name: string;
  email: string;
  role: 'user' | 'admin' | 'moderator';
  image?: string;
}

export function useAuth(options?: {
  required?: boolean;              // ← 是否强制要求登录
  redirectTo?: string;             // ← 未登录时跳转地址
  allowedRoles?: string[];         // ← 允许的角色
}) {
  const { data: session, status } = useSession();
  const router = useRouter();
  
  const isLoading = status === 'loading';
  const isAuthenticated = status === 'authenticated';
  const user = session?.user as AuthUser | undefined;

  // 检查角色权限
  const hasRequiredRole = options?.allowedRoles
    ? options.allowedRoles.includes(user?.role ?? '')
    : true;

  // 未登录时重定向
  useEffect(() => {
    if (isLoading) return;
    
    if (options?.required && !isAuthenticated) {
      router.push(options.redirectTo ?? '/login');
    }
    
    if (isAuthenticated && !hasRequiredRole) {
      router.push('/unauthorized');        // ← 无权限页面
    }
  }, [isLoading, isAuthenticated, hasRequiredRole]);

  // 登出
  const logout = async () => {
    await signOut({ 
      redirect: true, 
      callbackUrl: '/login' 
    });
  };

  // 获取当前用户ID(用于 SWR/React Query 的 key)
  const userId = user?.id;

  return {
    user,                                // ← 用户信息
    isLoading,                           // ← 加载状态
    isAuthenticated,                     // ← 是否已登录
    hasRequiredRole,                     // ← 是否有权限
    logout,                              // ← 登出函数
    userId,                              // ← 用户ID
  };
}

// 使用示例
function DashboardPage() {
  const { user, isLoading, logout } = useAuth({
    required: true,                      // ← 强制登录
    redirectTo: '/login',
  });

  if (isLoading) return <LoadingSpinner />;

  return (
    <div>
      <h1>Welcome, {user?.name}</h1>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

// 管理员页面
function AdminPage() {
  const { user, hasRequiredRole } = useAuth({
    required: true,
    allowedRoles: ['admin'],             // ← 只允许管理员
  });

  if (!hasRequiredRole) {
    return <AccessDenied />;
  }

  return <AdminPanel />;
}

Next.js 中间件路由保护

middleware.tstypescript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';

// 需要登录的路由
const protectedRoutes = ['/dashboard', '/profile', '/settings'];

// 需要管理员权限的路由
const adminRoutes = ['/admin'];

// 公开路由(已登录用户不应访问)
const authRoutes = ['/login', '/register'];

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // 获取 JWT Token(无需触发完整的 session 流程)
  const token = await getToken({
    req: request,
    secret: process.env.NEXTAUTH_SECRET,
  });

  const isAuthenticated = !!token;
  const isAdmin = token?.role === 'admin';

  // 检查是否为受保护路由
  const isProtectedRoute = protectedRoutes.some(route => 
    pathname.startsWith(route)
  );
  
  // 检查是否为管理员路由
  const isAdminRoute = adminRoutes.some(route => 
    pathname.startsWith(route)
  );
  
  // 检查是否为认证路由(登录/注册)
  const isAuthRoute = authRoutes.some(route => 
    pathname.startsWith(route)
  );

  // 未登录用户访问受保护路由 → 重定向到登录页
  if (isProtectedRoute && !isAuthenticated) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', pathname);  // ← 记录来源页
    return NextResponse.redirect(loginUrl);
  }

  // 非管理员访问管理员路由 → 重定向到无权限页
  if (isAdminRoute && !isAdmin) {
    return NextResponse.redirect(new URL('/unauthorized', request.url));
  }

  // 已登录用户访问登录页 → 重定向到仪表盘
  if (isAuthRoute && isAuthenticated) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  // 添加安全头
  const response = NextResponse.next();
  
  response.headers.set(
    'X-Frame-Options', 'DENY'
  );
  response.headers.set(
    'X-Content-Type-Options', 'nosniff'
  );
  response.headers.set(
    'Referrer-Policy', 'origin-when-cross-origin'
  );
  
  return response;
}

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};
工程全景

生产级安全防护清单

XSS 防护

跨站脚本攻击

Token 不要存 localStorage,改用内存变量 + httpOnly Cookie
使用 DOMPurify 过滤用户输入的 HTML
设置 Content-Security-Policy 响应头
Cookie 设置 httpOnly: true

CSRF 防护

跨站请求伪造

Cookie 设置 sameSite: 'lax' 'strict'
关键操作使用 CSRF Token 验证
JWT 方案天然免疫 CSRF(Token 存内存/手动添加 Header)
验证 Origin Referer

Token 无感刷新

双 Token 架构

Access Token
15-30 分钟
存内存,每次请求携带
Refresh Token
7-30 天
存 httpOnly Cookie

Access Token 过期时,自动用 Refresh Token 获取新的 Access Token,用户无感知。

强制 HTTPS

所有认证接口必须使用 HTTPS,防止中间人攻击截获 Token。

密钥管理

JWT Secret 至少 256 位,使用环境变量存储,定期轮换。推荐使用 RS256 非对称加密。

登录限流

限制登录尝试次数(如 5次/15分钟),防止暴力破解。使用 Redis 记录尝试次数。

审计日志

记录所有认证事件:登录、登出、Token 刷新、权限变更。便于安全审计和异常检测。

Cookie 安全配置速查

cookie-security.tstypescript
123456789101112131415161718192021222324252627282930
// 生产环境 Cookie 完整配置
const secureCookieOptions = {
  name: '__Host-auth-token',       // ← 使用 __Host- 前缀(最严格)
  value: token,
  httpOnly: true,                  // ← JS 无法读取,防 XSS
  secure: true,                    // ← 仅 HTTPS 传输
  sameSite: 'lax',                 // ← 防 CSRF(strict 最严格)
  maxAge: 60 * 60 * 24 * 7,       // ← 7天过期
  path: '/',                       // ← 全站有效
  // domain: '.example.com',       // ← __Host- 前缀不能设置 domain
};

// __Host- 前缀的规则(浏览器强制执行):
// 1. 必须设置 secure: true
// 2. 必须设置 path: '/'
// 3. 不能设置 domain
// 4. 这些规则由浏览器强制执行,非 JS 可绕过

// __Secure- 前缀(较宽松):
// 1. 必须设置 secure: true
// 2. 可以设置 domain

// 设置 Cookie
response.cookies.set(secureCookieOptions);

// 读取 Cookie(服务端)
const token = request.cookies.get('__Host-auth-token')?.value;

// 删除 Cookie
response.cookies.delete('__Host-auth-token');
企业级方案

SSO 单点登录

一次登录,处处通行。企业内部多个系统共享身份认证的终极方案。

SSO 认证流程

1
用户访问应用 A
app-a.example.com
2
未登录,重定向到 SSO
sso.example.com?callback=app-a
3
用户在 SSO 登录
统一认证中心验证身份
4
SSO 返回票据(Ticket)
redirect: app-a?ticket=xxx
5
应用 A 后端验证票据
调用 SSO API 验证 ticket
6
用户访问应用 B
app-b.example.com
7
SSO Cookie 已存在
自动登录,无需再次输入密码

SSO 客户端集成

lib/sso-client.tstypescript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL;
const APP_ID = process.env.NEXT_PUBLIC_APP_ID;

// SSO 客户端 SDK
export const sso = {
  // 1. 跳转到 SSO 登录页
  login(callbackUrl?: string) {
    const redirectUri = encodeURIComponent(
      callbackUrl ?? window.location.href
    );
    
    window.location.href = 
      `${SSO_URL}/authorize?` +
      `client_id=${APP_ID}&` +
      `redirect_uri=${redirectUri}&` +
      `response_type=code&` +
      `scope=openid profile email`;
  },

  // 2. 处理 SSO 回调(在回调页使用)
  async handleCallback(code: string) {
    const response = await fetch(`${SSO_URL}/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        code,                            // ← 授权码
        client_id: APP_ID,
        client_secret: process.env.SSO_CLIENT_SECRET,
        redirect_uri: `${window.location.origin}/callback`,
      }),
    });

    const { access_token, id_token } = await response.json();
    
    // access_token: 用于访问 API
    // id_token: 包含用户身份信息(JWT 格式)
    
    return { access_token, id_token };
  },

  // 3. 检查 SSO 登录状态
  async checkSession(): Promise<boolean> {
    try {
      const response = await fetch(`${SSO_URL}/session/check`, {
        credentials: 'include',          // ← 携带 SSO Cookie
      });
      const { active } = await response.json();
      return active;
    } catch {
      return false;
    }
  },

  // 4. SSO 登出(登出所有应用)
  logout() {
    window.location.href = 
      `${SSO_URL}/logout?` +
      `client_id=${APP_ID}&` +
      `post_logout_redirect_uri=` +
      encodeURIComponent(window.location.origin);
  },
};

// 回调页组件
export default async function SSOCallbackPage({
  searchParams,
}: {
  searchParams: { code?: string; error?: string };
}) {
  if (searchParams.error) {
    return <div>登录失败: {searchParams.error}</div>;
  }

  if (searchParams.code) {
    const tokens = await sso.handleCallback(searchParams.code);
    // 存储 token 并跳转...
    redirect('/dashboard');
  }

  return <div>处理中...</div>;
}

SSO 实现方案对比

CAS

Central Authentication Service

耶鲁大学开发的开源 SSO 协议,基于票据验证。广泛用于教育和政府机构。

优点:成熟稳定、开源免费、社区活跃
缺点:配置复杂、仅支持 Web
SAML 2.0

Security Assertion Markup Language

基于 XML 的企业级 SSO 标准。支持细粒度权限断言,适合大型企业。

优点:企业级标准、细粒度权限、跨域支持
缺点:XML 繁重、移动端支持差
OIDC

OpenID Connect

基于 OAuth 2.0 的现代身份认证层。使用 JWT,是当前最流行的 SSO 方案。

优点:现代标准、JWT 轻量、移动端友好
缺点:需要 HTTPS、实现细节多
速查清单

鉴权方案选型速查表

Session 方案

存储Redis
传输httpOnly Cookie
过期滑动 30min
CSRFCookie 自带
XSShttpOnly 防护
✅ 适用:传统 Web、高安全、即时失效场景

JWT 方案

Access内存变量
RefreshhttpOnly Cookie
Access 过期15 分钟
签名算法RS256
传输Authorization Header
✅ 适用:SPA、移动端、微服务、跨域场景

OAuth 2.0 方案

流程Auth Code + PKCE
存储按应用类型
Token 类型Bearer
Scope最小权限原则
库推荐NextAuth.js
✅ 适用:第三方登录、开放平台、企业集成

选型决策树

🏢 企业内部系统

多个内部系统需要统一登录 → SSO (OIDC)
使用 Keycloak / Auth0 / 自建 SSO 服务

📱 移动端 / SPA

单页应用或移动端 → JWT + Refresh Token
Access Token 存内存,Refresh Token 存 httpOnly Cookie

🌐 传统 Web 应用

服务端渲染、高安全要求 → Session + Cookie
使用 Redis 存储,天然 CSRF 防护

🔗 第三方登录

接入 GitHub/Google/微信登录 → OAuth 2.0
推荐使用 NextAuth.js / Lucia Auth 简化集成