PAYATHON 2026

RedwoodJS 请求 Python 后端时,携带 Authorization 为何出现 CORS 错误?

排障赵师傅

报错说明,浏览器发出的 OPTIONS /profile 预检请求没有收到符合要求的 CORS 响应。先检查这条请求经过的路由、鉴权中间件和代理,再调整后端的 CORS 配置。

Authorization 和 Content-Type: application/json 都会触发预检。删除这些请求头后能访问 /login,可能只是因为请求避开了预检,不能据此判断后端已正确处理 CORS。

为什么 /profile 会多一次预检

页面来自 http://localhost:8910,接口位于 http://localhost:8087。两者端口不同,属于不同的 origin,浏览器会执行 CORS 检查。

发送当前的 /profile 请求前,浏览器通常会先发送:

OPTIONS /profile HTTP/1.1
Origin: http://localhost:8910
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization,content-type

这条请求用来确认:后端是否允许该页面发送携带这些请求头的 GET 请求。预检本身不携带业务请求中的 Bearer token,因此不能要求它先通过 token 鉴权。

后端需要返回成功状态,并在响应头中给出相应的允许信息,例如:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:8910
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Authorization, Content-Type
Vary: Origin

预检通过后,浏览器才会发送实际的 GET /profile。实际响应也必须包含正确的 Access-Control-Allow-Origin,返回 401 时也一样。

原来的 /login 使用字符串作为请求体,没有显式设置 Content-Type,Fetch 通常会为它生成 text/plain;charset=UTF-8。如果没有其他触发预检的条件,这个 POST 就可以作为简单请求发送。

JSON.stringify() 只生成字符串,不会自动将请求的媒体类型设为 application/json。如果 /login 接收 JSON,就应该正确声明类型,并让后端处理由此产生的预检。

先检查浏览器实际收到的响应

打开开发者工具的 Network 面板,找到 OPTIONS /profile,检查:

  1. 请求地址是否为 http://localhost:8087/profile,是否发生了重定向。
  2. 响应是否返回成功状态,还是 401、403、404、405 或 500。
  3. 响应头是否包含正确的 Access-Control-Allow-Origin。
  4. Access-Control-Allow-Headers 是否明确允许 Authorization 和 Content-Type。

也可以用以下命令模拟预检:

curl -i -X OPTIONS 'http://localhost:8087/profile' \
  -H 'Origin: http://localhost:8910' \
  -H 'Access-Control-Request-Method: GET' \
  -H 'Access-Control-Request-Headers: authorization,content-type'

用 curl 时,需要查看响应状态和响应头;它不会像浏览器一样执行 CORS 拦截。

根据预检的返回结果排查:

  • 401:检查鉴权中间件是否拦截了 OPTIONS。
  • 404 或 405:检查预检路由是否注册成功、路径是否匹配,以及请求是否到达预期服务。
  • 3xx:检查尾部斜杠、HTTP/HTTPS 跳转或登录跳转,尽量直接请求最终地址。
  • 500:先查看服务端异常,错误响应可能没有经过添加 CORS 头的逻辑。
  • 成功状态但缺少 CORS 头:检查响应是否由代理或其他中间件生成。

展示的路由没有调用原代码中的 handle_cors_response(),所以修改它的白名单不会自动改变 handle_cors() 的行为。

如果 handle_cors() 确实执行成功,也收到了正确的 Origin,就应当返回 Access-Control-Allow-Origin。浏览器仍然报告缺少该响应头时,可以通过服务端访问日志确认请求是否进入这个处理函数,并确认 8087 端口上运行的服务是否已包含修改。

Bruno 通常不执行浏览器的同源策略,也不一定自动发送预检。在 Bruno 中请求成功,只能说明它发出的请求得到了接口响应,无法据此确认 CORS 配置是否正确。

后端统一使用 CORS 中间件

题目没有给出 Python 框架,也没有说明 Request、Response 从哪里导入,仅凭装饰器无法确定框架。应优先使用实际框架提供的 CORS 中间件,统一处理预检和实际响应。

下面以 FastAPI、Starlette 的 CORSMiddleware 和 PyJWT 为例。settings 沿用项目现有配置;使用其他框架时,需要换成对应的 API。

import jwt

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.middleware.cors import CORSMiddleware

# settings 沿用项目现有的导入和配置。

api = FastAPI()


@api.get("/profile")
async def profile(request: Request):
    auth_header = request.headers.get("Authorization", "")
    parts = auth_header.split()

    if len(parts) != 2 or parts[0].lower() != "bearer":
        return JSONResponse(
            status_code=401,
            content={"error": "Unauthorized"},
        )

    token = parts[1]

    try:
        decoded_token = jwt.decode(
            token,
            settings.JWT_SECRET,
            algorithms=["HS256"],
            audience="authenticated",
        )
    except jwt.ExpiredSignatureError:
        return JSONResponse(
            status_code=401,
            content={"error": "Token expired"},
        )
    except jwt.InvalidTokenError:
        return JSONResponse(
            status_code=401,
            content={"error": "Invalid token"},
        )

    return JSONResponse(
        content={"data": {"user": decoded_token}},
    )


# 其他路由,例如 /login,也注册在 api 上。

app = CORSMiddleware(
    app=api,
    allow_origins=["http://localhost:8910"],
    allow_methods=["GET", "POST"],
    allow_headers=["Authorization", "Content-Type"],
    allow_credentials=False,
)

ASGI 服务入口应指向包装后的 app。把 CORS 中间件放在应用外层,也有助于让应用内部生成的错误响应带上 CORS 头。如果错误响应由外部代理生成,仍需检查代理配置。

使用这个示例时,需要注意:

  • 前端通过请求头手动发送 token,没有使用跨域 Cookie,因此这里设置 allow_credentials=False。
  • HS256、密钥和 audience 沿用题目中的验证方式,实际值必须与 token 的签发配置一致。
  • 所有路由的 CORS 都交给中间件处理,无需再手写通配 OPTIONS 路由和 CORS 响应头。

如果使用跨域 Cookie,前端需要按需求设置 credentials: 'include',后端需要启用 allow_credentials=True,并配置明确的 origin 白名单。

如果继续手写 CORS,至少要先校验 origin 白名单,让预检跳过业务鉴权,并确保允许的实际响应和错误响应都带有正确的 CORS 头。按 origin 动态返回响应时,还应设置 Vary: Origin。原来的 get_response_headers() 直接回显任意 Origin,没有检查白名单,不适合作为正式配置。

前端保留 Authorization,删除无用途的请求头

GET /profile 没有请求体,无需设置 Content-Type,可以改为:

const response = await fetch(
  `${config.REDWOOD_ENV_API_ENDPOINT}/profile`,
  {
    method: 'GET',
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/json',
    },
  }
)

删除 Content-Type 能减少一个不必要的请求头,但 Authorization 仍会触发预检,后端必须正确处理。

mode: 'cors' 是普通跨域 fetch 的默认模式,可以保留,也可以省略。设置它无法替后端生成 CORS 响应头。

接收 JSON 的登录接口应明确设置请求类型:

const response = await fetch(
  `${config.REDWOOD_ENV_API_ENDPOINT}/login`,
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ email, password }),
  }
)

这样调整后,/login 也可能产生预检,属于正常行为。

为什么改成 * 仍然无效

如果响应没有经过 CORS 处理逻辑,修改通配符也无济于事。通配符本身还有使用限制:

  • 请求使用 credentials: 'include' 时,Access-Control-Allow-Origin: * 不符合要求,必须返回具体的 origin。
  • Authorization 必须在 Access-Control-Allow-Headers 中明确列出,不能依赖 *。
  • Access-Control-Allow-Methods、Access-Control-Allow-Headers 中通配符的语义也受凭据模式影响。当前场景直接列出方法和请求头,更容易验证。

手动设置 Authorization 并不要求同时设置 credentials: 'include'。仅使用 Bearer token 时,无需为了发送这个请求头而开启跨域 Cookie。

也不要改用 mode: 'no-cors'。该模式限制可设置的请求头,无法按当前方式发送 Authorization;返回的 opaque response 也不允许 JavaScript 读取状态和 JSON 内容,无法完成身份验证流程。

CORS 修复后,再检查鉴权逻辑

原后端还有一处独立的鉴权问题:

auth_header.startswith("bearer ")

这个判断区分大小写,会拒绝常见的 Bearer <token> 写法。认证方案名称应按大小写不敏感的方式比较,前面的示例使用 parts[0].lower() 处理了这一点。HTTP 请求头字段名本身也不区分大小写,通常无需分别读取 Authorization 和 authorization。

如果实际使用 FastAPI,还应注意:

  • 路由参数应声明为 request: Request。
  • FastAPI/Starlette 的 Response 不使用 description= 设置响应体;JSON 响应可以使用 JSONResponse(content=...)。

具体是否需要这样修改,应以项目实际使用的框架和导入为准。

验证时,先确认预检成功,再携带有效 token 请求 /profile,同时检查无效 token 返回的 401 能否被前端正常读取。实际响应也通过 CORS 检查后,前端才能区分鉴权失败和网络错误。

原前端在所有 catch 中直接调用 logOut(),会把 CORS 故障、服务不可用等情况都当作登录失效。应在明确收到表示 token 无效的响应时清理登录状态,并单独处理网络错误。登录代码中记录包含密码的完整请求体的语句也应删除。

备注:内容仅供参考。