ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring AI MCP 服务安全实践:用 API Key 认证锁住工具调用入口

Spring AI MCP 服务安全实践:用 API Key 认证锁住工具调用入口 前段时间帮一个客户评审 AI Agent 项目他们的 Spring AI 服务已经接了好几个 MCP 工具——查订单、读库存、触发工单。功能没问题问题出在安全上这些工具直接挂在公网没有任何认证等于把企业核心操作接口原样暴露出去。我当时的建议很简单在最前面加一把锁用 Spring Security 做 API Key 认证。这个需求听起来基础真正落地时还是有不少细节我把完整过程记录下来。这篇分享适合正在用 Spring AI 暴露 MCP 工具的后端开发者或者想把 MCP Server 在团队内部安全共享的架构师。如果你还停留在“内网地址没人知道”这种认知那更要读下去。我们这里不讨论 MCP 协议本身的语义只讲清楚一件事如何用 Spring Security 和 API Key把一个公开可访问的 Spring AI MCP 服务收回到只有持有密钥的客户端才能调用。1. MCP 服务的安全边界先搞清楚要保护什么1.1 Spring AI 里的 MCP Server 到底暴露了什么MCP 的全称是 Model Context Protocol模型上下文协议。它解决了 AI 应用和外部工具、数据源之间的通信标准问题。在 Spring AI 项目中你只需要在任意 Spring Bean 的方法上打一个Tool注解方法就会变成一个 MCP 工具可以被支持 MCP 协议的客户端发现和调用。举个例子下面这段代码几乎是最小可运行的 MCP 工具Component public class OrderTools { Tool(description 根据订单号查询订单状态) public String getOrderStatus( ToolParam(description 订单号) String orderId) { // 内部逻辑查数据库、调订单中心、返回状态 return orderService.getStatus(orderId); } }Spring AI 的 MCP Server 会把OrderTools注册为 MCP 端点客户端通过 JSON-RPC 调用tools/list可以枚举所有工具通过tools/call可以执行具体方法。危险就藏在这里只要 HTTP 端口暴露任何能访问这个地址的人都可以枚举你的工具清单了解系统内部有哪些业务能力调用getOrderStatus查询任意订单号可能造成越权访问如果工具里有写操作比如发送工单、修改配置、写入文件后果更严重反复调用消耗服务资源成为自动化攻击的跳板。很多团队把 MCP 服务和 AI Agent 放在同一个应用里想的就是“AI 能调工具就行”却忽略了工具本身就是一组有真实副作用的 API。MCP 客户端通常会自动组合多个工具完成复杂任务一旦被恶意利用它不是只调一个接口而是可以编排一连串操作。1.2 未认证的后果可能比数据库裸奔更隐蔽数据库裸奔时至少数据库端口和连接串还有一层隔离普通攻击者未必能立刻拿到数据。但 MCP 服务是标准的 HTTP 端点路径固定请求方式公开客户端 SDK 一堆扫描到 Spring Boot 应用后很容易继续探测/mcp、/api/mcp这类路径。更隐蔽的是MCP 工具返回的内容是给 AI 模型消费的结构化文本攻击者可以直接利用工具返回信息做二次利用。比如某个工具设计为“读取用户上传的文件”在无认证状态下任何人都可以遍历文件名某工具设计为“调用外部大模型生成摘要”攻击者可以刷你的额度。这些损失往往不是直接扣钱而是数据泄露、费用消耗、业务被篡改事后很难追溯。我在实际项目里遇到过更头疼的情况MCP 服务没有认证但是有监听随机端口。团队觉得端口随机就安全了结果一条“重启后端口变了客户端要怎么连”的问题暴露了他们所有 MCP 模块都可以被局域网内任意主机直接访问。随机端口只能增加扫描成本真正该做的是认证和授权。1.3 为什么路径隐蔽和随机端口不能替代认证有人喜欢把 MCP 端点放在一个很长的随机路径上比如/internal/ai/tools/9f2d61e4-28a1-4f2a-927e-8e1e5c5e7f0a然后告诉我“这样别人猜不到”。猜不到不等于没有风险Spring Boot 应用通常会暴露/actuator、/error、/swagger-ui等常见路径攻击者通过这些入口可能获得路由信息日志系统、API 网关、链路追踪组件都可能记录完整 URL安全扫描器也会尝试枚举路径字典长路径顶多增加一点点枚举成本。认证才是边界。API Key 是成本最低、最容易让客户端接受的一种方案。它不需要用户体系、不需要跳转登录页、不需要证书管理客户端只需在请求头里带一个固定字符串即可。对于内部团队使用的 MCP 服务这已经足够。2. API Key 认证方案设计Spring Security 为什么是更优解2.1 API Key 与其他认证方式对比在设计认证方案之前我习惯先拉一张对比表避免脑袋一热就上手写代码。MCP 服务常见的认证方式有三种API Key、JWT/OAuth2、mTLS。方案适用场景优点缺点API Key内部工具服务、B端固定客户端实现简单、调试方便、客户端零依赖密钥需要妥善保管无法快速识别用户只能整体轮换JWT / OAuth2多租户、第三方开发者可表达身份和权限可配合刷新令牌撤销访问需要 token 端点、密钥签名、权限模型实现复杂度高mTLS服务间高安全通信双向证书认证传输层自带身份证书签发和运维成本高外部浏览器/工具客户端很难接入对大多数 Spring AI MCP 服务来说使用者不是终端用户而是内部 AI Agent、开发工具、脚本和固定业务服务。API Key 足够解决问题。如果你未来要开放给第三方开发者再往 OAuth2 方向演进也不迟Spring Security 的安全过滤模型可以平滑替换。2.2 请求头约定X-API-Key 还是 AuthorizationAPI Key 放在哪里是个细节但直接影响客户端接入体验。最常见的两种做法是自定义头X-API-Key: your-key标准头Authorization: Bearer your-key很多 AI 客户端、MCP 调试工具内置了对Authorization: Bearer的支持因为大模型 API 普遍用这种格式。但也有不少内部脚本习惯用X-API-Key。为了避免客户端为实现而争吵我建议服务端同时兼容两种优先读取X-API-Key读不到时再看Authorization头里的Bearer前缀。private String resolveApiKey(HttpServletRequest request) { String xApiKey request.getHeader(X-API-Key); if (xApiKey ! null !xApiKey.isBlank()) { return xApiKey.trim(); } String authorization request.getHeader(Authorization); if (authorization ! null authorization.startsWith(Bearer )) { return authorization.substring(7).trim(); } return null; }注意不要支持?apiKeyxxx这种查询参数方式。查询参数容易出现在访问日志、网关日志、浏览器历史记录里密钥泄露风险远大于放在 Header 中。2.3 为什么还是用 Spring Security而不是手写一个 Servlet Filter只验证请求头的话自己写一个OncePerRequestFilter确实很快十分钟就能完成。但真实项目不会只有认证后续几乎一定会遇到CORS 策略、CSRF 防护、异常响应、安全响应头、会话策略、接口限流、审计日志。这些能力 Spring Security 已经帮你封装好了你只写一个过滤器接入到过滤链里就行。另一个原因是MCP 端点不是普通 Spring MVC ControllerPreAuthorize这类基于 AOP 的方法级安全有时不会拦截 MCP 工具调用。工具调用由 Spring AI 的 MCP dispatcher 接管不走HandlerInterceptor那套逻辑。所以真正可靠的控制点就是SecurityFilterChain这一层。与其在业务工具类里到处加判断不如在 HTTP 入口统一认证。3. Spring Boot 3 配置迁移从 WebSecurityConfigurerAdapter 到 SecurityFilterChain3.1 迁移背景很多 Spring Boot 2.x 时期的项目安全配置长这样Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers(/mcp/**).authenticated(); } }Spring Security 5.8 开始标记WebSecurityConfigurerAdapter为废弃Spring Boot 3对应的 Spring Security 6.x已经删除这个基类。网上能搜到大量基于 Spring Boot 2 的旧教程直接复制到 Spring Boot 3 里是编译不过的。正确的做法是声明一个SecurityFilterChainBean用 lambda 配置。以 Spring Boot 3.2 以后的标准写法为例Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth - auth.anyRequest().permitAll()); return http.build(); }如果你在升级旧项目建议直接按新的SecurityFilterChain模型重写安全配置而不是通过兼容配置保留旧类。保留旧类虽然能编译但后续升级 Spring Security 7 时还会再踩一遍坑。3.2 默认行为带来的坑只引入spring-boot-starter-security不写任何安全配置时Spring Boot 会有一个默认的过滤器链表单登录、HTTP Basic、随机生成的 user 密码。也就是说你刚把 MCP 服务部署好/mcp端点会返回重定向到登录页而不是 401 JSON客户端根本没法接。这个默认行为很容易误导调试。你会看到 MCP 客户端报“连接失败”或“收到 HTML 响应”而不是明确的 401。排查时要先确认自己的SecurityFilterChain是否生效再看端点的实际响应头。3.3 SecurityFilterChain 的多链路匹配Spring Security 6 支持定义多个SecurityFilterChain的 Bean按Order排序第一个匹配请求的就生效。这很适合我们的场景/mcp/**走 API Key 认证链路其他路径可以放行或走另一套规则。Bean Order(1) SecurityFilterChain mcpSecurityFilterChain(HttpSecurity http) throws Exception { http .securityMatcher(/mcp/**) // 接下来配置 API Key 认证 ; return http.build(); } Bean Order(2) SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth - auth.anyRequest().permitAll()); return http.build(); }securityMatcher是链路的入口。只有当请求路径匹配/mcp/**时才会进入 API Key 认证逻辑。这样不会影响应用的静态资源、健康检查、普通接口。4. 完整落地用 API Key 锁住 Spring AI MCP Server4.1 依赖与版本说明以 Spring Boot 3.3 / Spring AI 1.0.0 这个组合为例Maven 依赖如下dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-server/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency /dependencies注意 Spring AI 版本更新很快1.0.0-M预览版和2.0.0的某些自动配置项可能不同。我在项目中通常把 Spring AI 版本提到是1.0.0正式版或更高然后根据启动日志确认 MCP 端点路径。多数情况下默认端点是/mcp但你还是要在测试环境里实测确认。4.2 定义 MCP 工具为了演示我写一个很简单的 MCP 工具模拟查询告警信息Component public class AlertTools { Tool(description 根据告警 ID 查询告警详情) public String getAlertDetail( ToolParam(description 告警 ID) String alertId) { return 告警 alertId 状态: PENDING, 级别: P1; } }启动应用后Spring AI 自动把这个工具注册到 MCP Server。如果不用任何安全配置直接 POST 到/mcp就能看到工具列表。4.3 设计密钥校验器密钥校验不一定要走完整的AuthenticationProvider因为内部静态密钥比对逻辑很直白。我更建议轻量封装一个ApiKeyVerifier把密钥来源统一管理起来。Component public class ApiKeyVerifier { private final SetString validKeys; public ApiKeyVerifier(Value(${mcp-security.api-keys:}) String rawKeys) { this.validKeys Arrays.stream(rawKeys.split(,)) .map(String::trim) .filter(key - !key.isEmpty()) .collect(Collectors.toSet()); } public boolean isValid(String apiKey) { return validKeys.contains(apiKey); } }对应的application.ymlmcp-security: api-keys: local-key-001,team-a-key-002生产环境请不要把密钥直接写在 YAML 里用环境变量覆盖mcp-security: api-keys: ${MCP_SECURITY_API_KEYS}环境变量里用逗号分隔多个 key比如MCP_SECURITY_API_KEYSkey1,key2。4.4 自定义 API Key 认证过滤器这个OncePerRequestFilter的任务是从 Header 中解析出 API Key如果校验通过就把认证信息写入SecurityContext。public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { private final ApiKeyVerifier apiKeyVerifier; public ApiKeyAuthenticationFilter(ApiKeyVerifier apiKeyVerifier) { this.apiKeyVerifier apiKeyVerifier; } Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String apiKey resolveApiKey(request); if (apiKey ! null apiKeyVerifier.isValid(apiKey)) { ListGrantedAuthority authorities List.of(new SimpleGrantedAuthority(ROLE_MCP)); ApiKeyAuthenticationToken authentication new ApiKeyAuthenticationToken(apiKey, authorities); SecurityContext context SecurityContextHolder.createEmptyContext(); context.setAuthentication(authentication); SecurityContextHolder.setContext(context); } try { filterChain.doFilter(request, response); } finally { SecurityContextHolder.clearContext(); } } }这里的关键点是如果 key 不存在或者 key 校验失败我们什么都不做只是放行。后续由ExceptionTranslationFilter发现没有认证从而返回 401。如果错误地直接返回 403客户端会有点懵因为明明是没带密钥而不是权限不足。ApiKeyAuthenticationToken是一个自定义 Token继承AbstractAuthenticationTokenpublic class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { private final String apiKey; public ApiKeyAuthenticationToken(String apiKey, Collection? extends GrantedAuthority authorities) { super(authorities); this.apiKey apiKey; setAuthenticated(true); } Override public Object getCredentials() { return apiKey; } Override public Object getPrincipal() { return mcp-client; } }也许你会问为什么不直接在 Filter 里校验失败时调用response.sendError(401)还要走后续链我的经验是统一交给AuthenticationEntryPoint处理更容易保持 JSON 响应格式一致以后想增加审计日志或者跳转逻辑只需要改一个入口。4.5 SecurityConfig 完整配置现在把这些组件串起来Configuration EnableWebSecurity public class SecurityConfig { Bean Order(1) SecurityFilterChain mcpSecurityFilterChain(HttpSecurity http, ApiKeyVerifier apiKeyVerifier) throws Exception { ApiKeyAuthenticationFilter filter new ApiKeyAuthenticationFilter(apiKeyVerifier); http .securityMatcher(/mcp/**) .csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/actuator/health).permitAll() .anyRequest().hasRole(MCP)) .exceptionHandling(ex - ex .authenticationEntryPoint((request, response, authException) - { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write( {\code\:\api_key_required\,\message\:\API key is required in X-API-Key or Authorization Bearer header\}); }) .accessDeniedHandler((request, response, accessDeniedException) - { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-8); response.getWriter().write( {\code\:\forbidden\,\message\:\invalid api key or insufficient permission\}); })) .addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean Order(2) SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth - auth.anyRequest().permitAll()); return http.build(); } }我特意在配置里加了/actuator/health放行。很多 MCP 客户端和运维系统会先探活如果连健康检查都被 401 挡住会误判服务不可用。你也可以根据自己的运维需求调整白名单但原则是尽量只放开“不需要认证且没有业务数据暴露”的端点。hasRole(MCP)等价于要求ROLE_MCP权限。我们给通过 API Key 校验的请求注入了这个角色所以只有携带正确 key 的请求能放行。未携带 key、key 错误、key 过期都会进入AuthenticationEntryPoint返回 401。5. 启动验证与请求测试5.1 不带 Key 的请求启动应用后先用 curl 测一下完全不带头的情况curl -i -X POST http://localhost:8080/mcp \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:1,method:tools/list,params:{}}预期响应是 401HTTP/1.1 401 Content-Type: application/json;charsetUTF-8 {code:api_key_required,message:API key is required in X-API-Key or Authorization Bearer header}这一步主要验证SecurityFilterChain的匹配路径和AuthenticationEntryPoint是否生效。如果返回 200十有八九是securityMatcher的路径与 Spring AI 实际暴露的 MCP 路径不一致。5.2 带错误 Key 的请求curl -i -X POST http://localhost:8080/mcp \ -H Content-Type: application/json \ -H X-API-Key: wrong-key \ -d {jsonrpc:2.0,id:1,method:tools/list,params:{}}预期响应同样是 401因为在 Filter 阶段错误 key 没有获得认证后续链路把它当作匿名请求处理。这里就不应该出现 200 或 403。如果返回 200说明 Filter 没有实例化或者ApiKeyVerifier配置的 key 列表为空。5.3 带正确 Key 的请求分别测试两种 Header 写法curl -i -X POST http://localhost:8080/mcp \ -H Content-Type: application/json \ -H X-API-Key: local-key-001 \ -d {jsonrpc:2.0,id:1,method:tools/list,params:{}}curl -i -X POST http://localhost:8080/mcp \ -H Content-Type: application/json \ -H Authorization: Bearer local-key-001 \ -d {jsonrpc:2.0,id:1,method:tools/list,params:{}}两种方式都应返回 200JSON-RPC 响应体里能看到 Spring AI 注册的 MCP 工具列表比如{ jsonrpc: 2.0, id: 1, result: { tools: [ { name: getAlertDetail, description: 根据告警 ID 查询告警详情, inputSchema: {...} } ] } }5.4 常见 401/403 排查我在实际联调中遇到过几类典型问题。第一请求返回的是 Spring Security 默认的 403 HTML 页面而不是我们自定义的 401 JSON。原因是没写exceptionHandling或者securityMatcher没匹配上请求落到了默认链路上。检查Order(1)的securityMatcher是否精确以及defaultSecurityFilterChain是不是把/mcp也放行了。第二MCP 客户端报unexpected status 401 unauthorized: incorrect api key provided。这类提示在浏览器和大模型客户端工具里很常见通常是客户端发送的 Header 名不对。例如它默认发的是Authorization: Bearer而你的 Filter 只读了X-API-Key。兼容两种 Header 能直接避免这个问题。第三带 key 正确但返回 403。多半是认证 Token 没有拿到ROLE_MCP角色。检查自定义 Token 构造时是否传了SimpleGrantedAuthority(ROLE_MCP)并且hasRole(MCP)是否刚好匹配。少传角色认证通过了但授权失败于是返回 403。第四tools/call调用时出现 401 但tools/list正常。这种场景不常见因为同一个链路应该统一生效。如果出现先检查路径是不是有上下文前缀比如网关转发/api/mcp内层应用可能只匹配了/mcp/**。安全匹配要以网关转发后到达应用的最终路径为准。6. 生产环境需要注意的细节6.1 密钥管理与轮换API Key 没有内置过期机制所以轮换要靠流程。我的习惯是至少维持两个有效 key一个给旧客户端过渡一个给新客户端切换。等所有客户端都切到新 key 后再把旧 key 从环境变量里移除并重启应用。如果你在日志中看到错误 key 请求别急着调代码。先记录 key 前缀和来源 IP判断是不是扫描器或者内部脚本写错了 key。密钥轮换后旧 key 不要立即删除至少要留一个观察周期避免客户端缓存导致眼巴巴看着别人正常而你这边服务不可用。密钥本身建议用随机生成的字符串长度至少 32 位不要用springai123这种。官方一点的说法是使用加密安全的随机数生成器实际做法是本地执行openssl rand -hex 32把输出复制到环境变量里。6.2 多客户端、多角色策略如果只有几个内部团队一个 key 列表就够了。但团队多了以后你需要知道“谁在调”否则没法审计。一个简单的演进方案是维护 key 到客户端名的映射校验时读取客户端名称放入 Token 的principal这样后续可以按客户端维度限流、计数、审计。public class ApiKeyVerifier { private final MapString, String keyToClientName new HashMap(); public ApiKeyVerifier(Value(${mcp-security.api-keys:}) String rawKeys) { for (String entry : rawKeys.split(,)) { if (entry.isBlank()) continue; String[] parts entry.split(:); if (parts.length 2) { keyToClientName.put(parts[0].trim(), parts[1].trim()); } } } public OptionalString getClientName(String apiKey) { return Optional.ofNullable(keyToClientName.get(apiKey)); } }对应配置mcp-security: api-keys: ${MCP_SECURITY_API_KEYS}环境变量里每个 key 用key:clientName表示比如key1:order-agent,key2:data-sync。这样同一个 MCP 服务既能识别客户端身份又不需要立刻引入 OAuth2。6.3 与 Spring Cloud Gateway 统一鉴权如果你的系统已经用了 Spring Cloud Gateway并且有多个 MCP Server 或多个业务服务我不建议在每个服务里各写一套 API Key 校验。更好的方式是在网关层统一拦截校验通过后再将请求转发到下游。网关层可以做两件事一是在GlobalFilter里解析并校验 API Key二是校验通过后把X-Client-Name等头写入转发的请求下游服务直接信任网关即可。这样 MCP 服务本身可以构建在相对可信的内网环境网关成为唯一的外部认证入口。这个设计对团队的意义不只是少写代码更重要的是统一了安全策略网关下线某个 key、限流、审计所有下游服务同时生效。如果你还在用 Spring Cloud 和 Spring AI 开发 Agent非常推荐把安全防护做在入口网关而不是分散到每个工具服务里。6.4 版本兼容性Spring Security 7 与 Spring AI 2.0热词里既有 Spring Security 7 也有 Spring AI 2.0说明社区已经开始关注下一代版本。Spring Security 7 的路线图我关注过整体会延续SecurityFilterChain模型API Key 这类自定义 Filter 的接入方式不会发生根本性变化。主要风险不在安全配置而在于 Spring Boot 3.x 到 4.x 的自动配置变化。Spring AI 2.0 对 MCP 的Tool注解和端点注册可能继续演进但安全边界的设计原则不会变MCP 端点不该裸奔。哪怕版本升级后默认端点路径变了只要你的securityMatcher跟着新配置调整认证逻辑基本可以迁移。升级版本时我建议先做一次端到端测试用正确 key 和错误 key 分别验证。不要只看“服务起来了”要确认/mcp的响应码和响应体符合预期。因为 Spring AI 自动配置变更时最容易被忽略的就是 MCP 端点路径和 Content-Type 处理。经验一则这轮改造做完之后我在项目里又做了一次内网扫描确认没有遗漏的 MCP 端点。后来查访问日志时发现每天都有不少无 key 请求打进来说明这把锁确实加对了。就我个人的习惯给 MCP 服务加 API Key 不是一道加分题而是上线前必须完成的一步。你可以在本地先按文章里的最小代码跑通再逐步加上自己的密钥管理、审计和网关策略。希望这篇分享能让你少踩几个坑。
返回列表