×

前端存 Token 到底存 Storage 还是 Cookie?综合方案来了

独孤求败 独孤求败 发表于2026-06-22 12:14:23 浏览31 评论0

抢沙发发表评论


同一个后端接口,往往要同时服务好几种客户端:浏览器后台、微信小程序、App、第三方系统调用……浏览器喜欢 Cookie,自动携带还防 XSS;小程序没有 Cookie 环境,只能拿 Token 塞 Header。

两边都要支持,怎么办?只选一个,另一边就废了。

所以正确的答案是:两个都要。登录时同时返回 JSON 给前端 + 种 HttpOnly Cookie,请求时先读 Header,没有再从 Cookie 回退。一套认证逻辑同时兼容所有客户端,前端代码量不增反减。

这篇直接带你过一遍 .NET 6+ 里"Storage + Cookie 综合方案"的完整落地代码。

两种方案的根本差异

维度
localStorage + Header
HttpOnly Cookie
凭证存储
前端手动存(localStorage)
浏览器自动存(脚本读不到)
请求携带
前端手动塞 Authorization 头
浏览器自动带
XSS 风险
高(脚本随便读)
低(HttpOnly 读不到)
CSRF 风险
天然免疫
需配置 SameSite / Token
跨域支持
天然支持
需配置 CORS + credentials
小程序/App
支持
不支持(无 Cookie 环境)
退出登录
前端删 localStorage 即可
后端必须写清除逻辑

综合方案的核心思路:

同时支持两种方式——带 Authorization 头的请求走 Header,不带头的请求从 Cookie 回退读取。浏览器登录后自动走 Cookie,小程序/第三方调用时手动塞 Header,一套 [Authorize] 覆盖所有客户端。

怎么引入

需要安装的包

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
图片

Program.cs 完整配置

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// ============ 1. CORS 配置 ============
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", policy =>
    {
        // 注意:URL 结尾千万不能加斜杠 "/"
        policy.WithOrigins(
                "https://你的前端域名.com",
                "http://localhost:3000",
                "http://localhost:5173"
            )
            .AllowCredentials()
            .AllowAnyHeader()
            .AllowAnyMethod();
    });
});
// ============ 2. 认证配置(仅 JWT Bearer,通过 OnMessageReceived 回退读 Cookie) ============
builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    var jwtSettings = builder.Configuration.GetSection("JwtSettings");
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidIssuer = jwtSettings["Issuer"],
        ValidateAudience = true,
        ValidAudience = jwtSettings["Audience"],
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(jwtSettings["SecretKey"])),
        ValidateLifetime = true,
        ClockSkew = TimeSpan.Zero
    };
    options.Events = new JwtBearerEvents
    {
        OnMessageReceived = context =>
        {
            string token = string.Empty;
            // 1. 优先从 Header 读取
            var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
            if (!string.IsNullOrWhiteSpace(authHeader))
            {
                if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
                {
                    authHeader = "Bearer " + authHeader;
                }
                token = authHeader.Substring("Bearer ".Length).Trim();
            }
            // 2. Header 没有,从 Cookie 回退
            if (string.IsNullOrWhiteSpace(token))
            {
                var path = context.HttpContext.Request.Path.Value;
                if (path != null && path.EndsWith("/refresh", StringComparison.OrdinalIgnoreCase))
                {
                    context.Request.Cookies.TryGetValue("refresh_token"out var refreshToken);
                    token = refreshToken;
                }
                else
                {
                    context.Request.Cookies.TryGetValue("access_token"out var accessToken);
                    token = accessToken;
                }
            }
            context.Token = token;
            return Task.CompletedTask;
        }
    };
});
builder.Services.AddAuthorization();
builder.Services.AddControllers();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseCors("AllowFrontend");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

核心逻辑:

OnMessageReceived 先读 Authorization 头,没有再读 Cookie。
 - /refresh 请求从 refresh_token Cookie 取,普通请求从 access_token Cookie 取。
 - 只注册 JWT Bearer 方案,不注册 AddCookie,避免方案混淆。

快速上手

示例一:登录接口

using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly IWebHostEnvironment _env;
    public AuthController(IWebHostEnvironment env)
    {
        _env = env;
    }
    [HttpPost("login")]
    public IActionResult Login([FromBody] LoginRequest request)
    {
        if (request.Username != "admin" || request.Password != "123456")
        {
            return Unauthorized(new { success = false, message = "用户名或密码错误" });
        }
        var user = new { Id = 1, Name = "Admin", Role = "Admin" };
        var accessToken = GenerateAccessToken(user, TimeSpan.FromMinutes(15));
        var refreshToken = GenerateRefreshToken(user, TimeSpan.FromDays(7));
        bool isDevelopment = _env.IsDevelopment();
        // 种 HttpOnly Cookie
        Response.Cookies.Append("access_token", accessToken, new CookieOptions
        {
            HttpOnly = true,
            Secure = !isDevelopment,
            SameSite = SameSiteMode.Lax,
            Expires = DateTimeOffset.UtcNow.AddMinutes(15),
            Path = "/"
        });
        // 注意:Path 必须与实际路由完全一致
        Response.Cookies.Append("refresh_token", refreshToken, new CookieOptions
        {
            HttpOnly = true,
            Secure = !isDevelopment,
            SameSite = SameSiteMode.Lax,
            Expires = DateTimeOffset.UtcNow.AddDays(7),
            Path = "/api/auth/refresh"
        });
        return Ok(new
        {
            success = true,
            token = accessToken,
            refreshToken = refreshToken,
            expiresIn = 900
        });
    }
    private string GenerateAccessToken(object user, TimeSpan expiresIn)
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new Claim(ClaimTypes.Name, user.Name),
            new Claim(ClaimTypes.Role, user.Role)
        };
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("your-secret-key"));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
        var token = new JwtSecurityToken(
            issuer: "your-issuer",
            audience: "your-audience",
            claims: claims,
            expires: DateTime.UtcNow.Add(expiresIn),
            signingCredentials: creds);
        return new JwtSecurityTokenHandler().WriteToken(token);
    }
    private string GenerateRefreshToken(object user, TimeSpan expiresIn)
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new Claim("token_type""refresh")
        };
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("your-secret-key"));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
        var token = new JwtSecurityToken(
            issuer: "your-issuer",
            audience: "your-audience",
            claims: claims,
            expires: DateTime.UtcNow.Add(expiresIn),
            signingCredentials: creds);
        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}
public class LoginRequest
{
    public string Username { getset; } = string.Empty;
    public string Password { getset; } = string.Empty;
}

示例二:刷新 Token 接口

[HttpPost("refresh")]
public IActionResult Refresh()
{
    var refreshToken = GetRefreshTokenFromRequest(Request);
    if (string.IsNullOrEmpty(refreshToken))
    {
        return Unauthorized(new { message = "No refresh token" });
    }
    var principal = ValidateRefreshToken(refreshToken);
    if (principal == null)
    {
        return Unauthorized(new { message = "Invalid refresh token" });
    }
    var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    if (string.IsNullOrEmpty(userId))
    {
        return Unauthorized(new { message = "Invalid refresh token payload" });
    }
    var user = new { Id = int.Parse(userId), Name = "Admin", Role = "Admin" };
    var newAccessToken = GenerateAccessToken(user, TimeSpan.FromMinutes(15));
    var newRefreshToken = GenerateRefreshToken(user, TimeSpan.FromDays(7));
    bool isDevelopment = _env.IsDevelopment();
    Response.Cookies.Append("access_token", newAccessToken, new CookieOptions
    {
        HttpOnly = true,
        Secure = !isDevelopment,
        SameSite = SameSiteMode.Lax,
        Expires = DateTimeOffset.UtcNow.AddMinutes(15),
        Path = "/"
    });
    Response.Cookies.Append("refresh_token", newRefreshToken, new CookieOptions
    {
        HttpOnly = true,
        Secure = !isDevelopment,
        SameSite = SameSiteMode.Lax,
        Expires = DateTimeOffset.UtcNow.AddDays(7),
        Path = "/api/auth/refresh"
    });
    return Ok(new
    {
        token = newAccessToken,
        refreshToken = newRefreshToken,
        expiresIn = 900
    });
}
private string GetRefreshTokenFromRequest(HttpRequest request)
{
    var authHeader = request.Headers["Authorization"].FirstOrDefault();
    if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer "))
    {
        return authHeader["Bearer ".Length..];
    }
    request.Cookies.TryGetValue("refresh_token"out var token);
    return token;
}
private ClaimsPrincipal ValidateRefreshToken(string token)
{
    var tokenHandler = new JwtSecurityTokenHandler();
    var key = Encoding.UTF8.GetBytes("your-secret-key");
    try
    {
        var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = "your-issuer",
            ValidAudience = "your-audience",
            IssuerSigningKey = new SymmetricSecurityKey(key)
        }, out _);
        return principal;
    }
    catch
    {
        return null;
    }
}

示例三:退出登录

[HttpPost("logout")]
[Authorize]
public IActionResult Logout()
{
    // 直接删除 Cookie
    Response.Cookies.Delete("access_token"new CookieOptions { Path = "/" });
    Response.Cookies.Delete("refresh_token"new CookieOptions { Path = "/api/auth/refresh" });
    return Ok(new { success = true, message = "已退出登录" });
}

前端怎么用

浏览器端

// 登录
const res = await axios.post('/api/auth/login', {
    username: 'admin',
    password: '123456'
});
// Cookie 已自动种好,无需任何存贮操作
// 跨域场景需开启
axios.defaults.withCredentials = true;
// 后续请求——浏览器自动带 Cookie
const data = await axios.get('/api/data');

小程序端

// 1. 登录——从响应里取 token 存缓存
const res = await request({ url: '/api/auth/login', method: 'POST', data: {...} });
wx.setStorageSync('access_token', res.data.token);
wx.setStorageSync('refresh_token', res.data.refreshToken);
// 2. 后续请求——手动塞 Authorization 头
const token = wx.getStorageSync('access_token');
wx.request({
    url: 'https://api.yourdomain.com/api/data',
    header: { 'Authorization'`Bearer ${token}` }
});

小程序刷新 token 注意:小程序没有 Cookie,刷新时需手动从缓存取 refresh_token 塞 Authorization 头,不能直接调 axios.post('/refresh')

常见报错与修复

报错
原因
修复
Cookie 未携带
跨域未配置 credentials 或 Path 不匹配
前端 withCredentials: true,后端 .AllowCredentials()
刷新接口拿不到 refresh_token
Cookie Path 与实际路由不一致
Path 改为 /api/auth/refresh
CORS 报错
WithOrigins
 末尾多了斜杠
去掉 URL 末尾的 /
本地 Cookie 写不进
Secure = true
 但本地是 HTTP
用 _env.IsDevelopment() 动态切换
小程序刷新失败
没有手动携带 refresh_token
从缓存取 refresh_token 塞 Header

选型建议

业务场景
推荐方案
纯浏览器后台管理
Cookie 认证
前后端分离 SPA
Cookie + withCredentials
小程序 / App
Header(JWT)
浏览器 + 小程序 / App 都有
综合方案(两个都要)

总结收尾

一个后端接口可能要服务多个客户端——浏览器喜欢 Cookie,小程序没有 Cookie 环境。只选一个,另一边就废了。

答案是:两个都要

综合方案的代码核心就三点:
 1. OnMessageReceived 里先读 Header,没有再读 Cookie。
 2. 登录时同时返回 JSON + 种 HttpOnly Cookie,Cookie 的 Path 必须与实际路由一致。
 3. 退出时直接删除 Cookie,不依赖 SignOutAsync

三条落地建议: 1. 先搞清楚客户端类型:只有浏览器?还是有小程序/App?
 2. 用文中的 AddJwtBearer + OnMessageReceived 模板,改一下密钥。
 3. 跨域场景前端 withCredentials: true,后端 .AllowCredentials()


群贤毕至

访客