JDK 17 HttpClient 实现 AWS SigV4 请求签名实战拆解

发布时间:2026/9/15 2:06:36
JDK 17 HttpClient 实现 AWS SigV4 请求签名实战拆解 在对接云存储、开放 API 或者企业内部网关时签名验证几乎是绕不开的一道坎。JDK 17 自带的 HttpClient 已经足够成熟但很多人习惯性依赖 OkHttp 或 Apache HttpClient反而忽略了原生客户端的组合能力。这篇文章把我自己用 JDK 17 HttpClient 实现 AWS SigV4 风格请求签名的过程完整拆开讲从签名原理到代码实现再到实际踩过的坑一次说清楚。1. 为什么请求签名这么重要1.1 一个真实的对接场景我在一次项目里要对接对象存储服务的上传接口对方用的是 AWS S3 兼容协议所有请求必须带 SigV4 签名。当时第一反应是找 SDK但项目里只是一个小工具模块引入完整 SDK 太重了而且内网环境的依赖管控很严格于是决定用 JDK 原生 HttpClient 自己实现签名逻辑。这个场景非常典型不是每个团队都有权限随便加依赖也不是每个服务都能跑在公网上用官方 SDK。很多时候你必须徒手实现签名协议把请求从构造到签名再到发送整个链路控制在自己手里。1.2 签名与 Token 认证的本质区别很多人会把签名和 Token 混为一谈。Token 是你有资格访问签名是这条请求确实是你发的。Token 一旦泄露攻击者可以拿着它随便调用接口而签名则是基于请求内容动态计算的每条请求的签名都不一样即使被截获也没法重放到其他请求上。SigV4 签名尤其严谨它把 HTTP 方法、路径、查询参数、关键请求头、请求体哈希全部纳入签名范围。只要请求在传输过程中被篡改任何一个字节服务端重新计算签名时就会发现对不上直接拒绝请求。这就是所谓的完整性保护。1.3 SigV4 签名的核心思想AWS SigV4 的流程可以概括成四步构造规范化请求Canonical Request。基于规范化请求生成待签字符串String to Sign。用密钥派生签名密钥Signing Key。用签名密钥对待签字符串做 HMAC-SHA256得到最终签名。这个流程看起来很绕核心其实就是把请求的所有关键要素拍平成一个标准字符串再做两次 HMAC。为什么要规范化因为 HTTP 协议本身是宽松的同一份请求可能有多种写法比如查询参数顺序不同、URL 编码方式不同服务端必须确保拿到任何合理写法的请求都能还原出同一个待签字符串否则一个合法的签名会因为请求格式的微小差异而校验失败。2. 用 JDK 17 HttpClient 作为请求底座2.1 为什么要用 JDK 原生 HttpClientJDK 11 引入的 HttpClient 一直被低估。到了 JDK 17它已经支持 HTTP/1.1 和 HTTP/2、同步和异步请求、WebSocket而且 API 设计清爽没有历史包袱。最直接的优势是不需要引入任何第三方依赖对于签名这种需要精细化控制请求头的场景特别方便。更重要的是HttpClient 的HttpRequest是构建者模式可以在发送前任意追加请求头。签名逻辑本质上就是根据请求内容计算一段请求头两者天然契合。2.2 构造一个可复用的 HttpClient 实例一个基础配置如下HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .version(HttpClient.Version.HTTP_1_1) .followRedirects(HttpClient.Redirect.NORMAL) .build();有几个细节值得注意connectTimeout一定要设置否则默认是无限等待连接建立线上环境一个网络抖动就可能让线程池被耗尽。followRedirects在签名场景要特别小心后面我会单独讲重定向导致认证信息丢失的问题。version设置为HTTP_1_1还是HTTP_2取决于服务端支持情况。S3 兼容网关有的对 HTTP/2 支持不完整个人建议签名场景统一用 HTTP/1.1少踩兼容性坑。2.3 与 OkHttp、Apache HttpClient 的取舍对比我知道很多团队习惯用 OkHttp它的拦截器机制非常成熟。但如果是纯 JDK 项目或者出于依赖瘦身、安全审计的考虑原生 HttpClient 完全够用。它们各自的定位差异我整理了一下维度JDK HttpClientOkHttpApache HttpClient依赖无JDK 内置需引入三方依赖需引入三方依赖HTTP/2支持支持支持拦截器无内置需自行组装Interceptor 机制成熟有类似链条机制异步sendAsync 返回 CompletableFuture支持但 API 较老较复杂适合场景轻量、无依赖场景成熟项目、需要拦截器生态老牌企业级项目我的结论是如果项目已经用了 OkHttp没必要强行替换如果是从零开始写一个需要签名的小模块JDK 原生 HttpClient 是最干净的选择。3. 签名核心逻辑实现——一步步拆解 SigV4这一节是文章的核心我把 SigV4 的每一步都拆开并给出完整的 Java 实现。3.1 第一步构造 Canonical Request规范化请求Canonical Request 是一个多行字符串格式如下HTTPMethod CanonicalURI CanonicalQueryString CanonicalHeaders SignedHeaders HashedPayload每一行都有讲究HTTPMethod就是GET、PUT、POST之类的大写方法名。CanonicalURI经过 URI 编码的路径。注意不是简单地把整个 URL 拿来用而是每个路径段单独编码同时要保留/分隔符。CanonicalQueryString查询参数按 key 排序后用keyvalue和连接。value 要做 URI 编码且空格编码为%20而不是。CanonicalHeaders参与签名的请求头格式是key:value\nkey 转小写value 去除首尾空格。通常至少包含host和x-amz-date。SignedHeaders参与签名的请求头 key 列表按字典序排序用;连接。HashedPayload请求体内容的 SHA-256 哈希的十六进制字符串。GET 请求通常是空字符串的哈希。下面是一个构造 Canonical Request 的核心代码public class SigV4CanonicalRequest { public static String build(String method, String canonicalUri, MapString, String queryParams, MapString, String headers, byte[] payload) throws Exception { // 1. 查询参数按 key 排序 MapString, String sortedQuery new TreeMap(queryParams); String canonicalQuery sortedQuery.entrySet().stream() .map(e - uriEncode(e.getKey()) uriEncode(e.getValue())) .collect(Collectors.joining()); // 2. 参与签名的请求头按 key 排序 MapString, String sortedHeaders new TreeMap(headers); String canonicalHeaders sortedHeaders.entrySet().stream() .map(e - e.getKey().toLowerCase(Locale.ROOT) : e.getValue().trim() \n) .collect(Collectors.joining()); String signedHeaders sortedHeaders.keySet().stream() .map(k - k.toLowerCase(Locale.ROOT)) .collect(Collectors.joining(;)); // 3. 计算请求体哈希 String hashedPayload sha256Hex(payload); return method \n canonicalUri \n canonicalQuery \n canonicalHeaders \n signedHeaders \n hashedPayload; } private static String uriEncode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8) .replace(, %20) .replace(*, %2A) .replace(%7E, ~); } private static String sha256Hex(byte[] data) throws Exception { MessageDigest digest MessageDigest.getInstance(SHA-256); byte[] hash digest.digest(data); return HexFormat.of().formatHex(hash); } }uriEncode里的三个替换是签名场景最容易忽略的地方。URLEncoder默认把空格编码成但签名协议要求%20*要编码成%2A~是 unreserved 字符要保持原样不被编码。这些细节不处理好签名在服务端就会失败而且错误信息往往很模糊。3.2 第二步生成 StringToSign待签字符串StringToSign 的格式如下AWS4-HMAC-SHA256 TimeStamp DateScope Hex(CanonicalRequest)其中TimeStampISO 8601 基本格式的 UTC 时间形如20250617T063000Z。注意必须用T分隔日期和时间结尾带Z没有毫秒和时区偏移。DateScope格式为YYYYMMDD/region/service/aws4_request比如20250617/us-east-1/s3/aws4_request。区域和服务名要与接口对应的配置一致写错了签名必然失败。Hex(CanonicalRequest)上一步产出的 Canonical Request 字符串做 SHA-256 后再转为十六进制。代码实现很直接public class SigV4StringToSign { public static String build(String timestamp, String dateScope, String canonicalRequest) throws Exception { MessageDigest digest MessageDigest.getInstance(SHA-256); String hashedCanonicalRequest HexFormat.of() .formatHex(digest.digest(canonicalRequest.getBytes(StandardCharsets.UTF_8))); return AWS4-HMAC-SHA256\n timestamp \n dateScope \n hashedCanonicalRequest; } }3.3 第三步派生签名密钥 SigningKey这一步是 SigV4 的精髓所在。它不是直接用 Access Key Secret 去签而是通过逐级 HMAC 派生出一个分层密钥公式如下kDate HMAC(AWS4 SecretKey, Date) kRegion HMAC(kDate, Region) kService HMAC(kRegion, Service) kSigning HMAC(kService, aws4_request)这样做的好处是即使某个服务的签名密钥泄露也只影响单个区域、单个服务、单个日期范围内的请求而不是整个账号的所有密钥全部暴露。每次 HMAC 的输入都比上一级收敛密钥的范围被层层缩小。实现代码public class SigV4SigningKey { public static byte[] derive(String secretKey, String date, String region, String service) throws Exception { byte[] kDate hmac(AWS4 secretKey, date); byte[] kRegion hmac(kDate, region); byte[] kService hmac(kRegion, service); return hmac(kService, aws4_request); } private static byte[] hmac(byte[] key, String data) throws Exception { Mac mac Mac.getInstance(HmacSHA256); mac.init(new SecretKeySpec(key, HmacSHA256)); return mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); } private static byte[] hmac(String key, String data) throws Exception { return hmac(key.getBytes(StandardCharsets.UTF_8), data); } }这里有个容易写错的点第一级 HMAC 的 key 是字符串AWS4 SecretKey注意AWS4前缀不能漏。SecretKey 本身是不参与传输的只有客户端持有。3.4 第四步计算最终签名并组装 Authorization 头最终签名就是对待签字符串做一次 HMAC-SHA256密钥用上一步派生的kSigningMac mac Mac.getInstance(HmacSHA256); mac.init(new SecretKeySpec(signingKey, HmacSHA256)); byte[] signatureBytes mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)); String signature HexFormat.of().formatHex(signatureBytes);然后组装 Authorization 请求头Authorization: AWS4-HMAC-SHA256 CredentialAKID/20250617/us-east-1/s3/aws4_request, SignedHeadershost;x-amz-date, Signature十六进制签名Credential 部分的格式是AccessKey/DateScopeAccessKey 是明文传输的服务端通过它找到对应的 SecretKey 来重新计算签名。SignedHeaders 要与 Canonical Request 里的一致服务端就是根据这个列表重新拼 Canonical Request 的。4. 把签名逻辑无缝集成进 HttpClient4.1 设计一个可复用的签名请求器签名逻辑和 HTTP 发送逻辑不应该混在一起写。我设计了一个统一的入口输入原始请求信息和凭证输出一个已经带好签名头的HttpRequest然后交给客户端发送。public class SigV4HttpClient { private final HttpClient httpClient; private final String accessKey; private final String secretKey; private final String region; private final String service; public SigV4HttpClient(String accessKey, String secretKey, String region, String service) { this.httpClient HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .version(HttpClient.Version.HTTP_1_1) .build(); this.accessKey accessKey; this.secretKey secretKey; this.region region; this.service service; } public HttpResponseString send(HttpRequest originalRequest) throws Exception { HttpRequest signedRequest sign(originalRequest); return httpClient.send(signedRequest, HttpResponse.BodyHandlers.ofString()); } public CompletableFutureHttpResponseString sendAsync(HttpRequest originalRequest) { try { HttpRequest signedRequest sign(originalRequest); return httpClient.sendAsync(signedRequest, HttpResponse.BodyHandlers.ofString()); } catch (Exception e) { return CompletableFuture.failedFuture(e); } } private HttpRequest sign(HttpRequest request) throws Exception { URI uri request.uri(); String method request.method(); String body request.bodyPublisher() .map(p - { // 这里简化处理实际需要拿到请求体字节内容 return ; }) .orElse(); // 当前时间注意要用 UTC ZonedDateTime now ZonedDateTime.now(ZoneOffset.UTC); String timestamp now.format(DateTimeFormatter.ofPattern(yyyyMMddTHHmmssZ)); String date now.format(DateTimeFormatter.ofPattern(yyyyMMdd)); byte[] payload body.getBytes(StandardCharsets.UTF_8); MapString, String headers new TreeMap(); headers.put(host, uri.getHost()); headers.put(x-amz-date, timestamp); // 也可以按需加 x-amz-content-sha256 String canonicalRequest SigV4CanonicalRequest.build( method, uri.getRawPath(), queryToMap(uri.getRawQuery()), headers, payload); String dateScope date / region / service /aws4_request; String stringToSign SigV4StringToSign.build(timestamp, dateScope, canonicalRequest); byte[] signingKey SigV4SigningKey.derive(secretKey, date, region, service); String signature calculateSignature(signingKey, stringToSign); String signedHeaders headers.keySet().stream() .map(k - k.toLowerCase(Locale.ROOT)) .collect(Collectors.joining(;)); String authorization AWS4-HMAC-SHA256 Credential accessKey / dateScope , SignedHeaders signedHeaders , Signature signature; // 在原始请求基础上追加签名相关头 HttpRequest.Builder builder HttpRequest.newBuilder(uri) .method(method, request.bodyPublisher().orElse(HttpRequest.BodyPublishers.noBody())); request.headers().map().forEach((k, v) - v.forEach(val - builder.header(k, val))); builder.header(x-amz-date, timestamp); builder.header(Authorization, authorization); return builder.build(); } private MapString, String queryToMap(String rawQuery) { MapString, String map new TreeMap(); if (rawQuery null || rawQuery.isEmpty()) { return map; } for (String pair : rawQuery.split()) { int idx pair.indexOf(); if (idx 0) { map.put(pair.substring(0, idx), pair.substring(idx 1)); } } return map; } private String calculateSignature(byte[] signingKey, String stringToSign) throws Exception { Mac mac Mac.getInstance(HmacSHA256); mac.init(new SecretKeySpec(signingKey, HmacSHA256)); return HexFormat.of().formatHex(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8))); } }4.2 处理 URI 编码与路径规范化上面代码里我用了uri.getRawPath()这一点非常重要。Java 的URI类有两个获取路径的方法getPath()会返回解码后的路径比如路径里有%20会被还原成空格而getRawPath()保留原始编码形态。签名计算必须使用原始编码形态因为服务端拿到的 HTTP 请求行里的路径就是原始编码后的形式。如果路径中包含中文字符或者特殊字符比如/documents/我的文件.txt客户端发送时会被编码成/documents/%E6%88%91%E7%9A%84%E6%96%87%E4%BB%B6.txt。签名时你只能对已经编码好的字符串再做一次SigV4 规范编码不能先解码再编码否则两边算出来不一致。4.3 完整调用链路示例组装一个完整的调用示例public class Demo { public static void main(String[] args) throws Exception { SigV4HttpClient client new SigV4HttpClient( AKID1234567890, secretKey, us-east-1, s3); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(https://bucket.s3.amazonaws.com/test.txt)) .header(Content-Type, text/plain) .PUT(HttpRequest.BodyPublishers.ofString(Hello SigV4)) .build(); HttpResponseString response client.send(request); System.out.println(response.statusCode()); System.out.println(response.body()); } }注意PUT方法带了请求体签名时HashedPayload要把请求体内的字节算进去。服务端收到后会重新计算请求体的 SHA-256与签名里的值比对。如果请求体在发送过程中被压缩插件、代理等组件悄悄改过签名校验就会失败。这也是签名比简单 Token 更安全的原因。5. 实战中的常见问题与排查实录5.1 时间偏移导致 SignatureDoesNotMatch签名请求里带时间戳服务端会检查客户端时间与服务端时间的偏差通常允许 15 分钟左右的容差。如果你的服务器时间没有同步哪怕签名算法完全正确服务端也会返回时间相关错误。我曾经碰到过一次诡异的问题本地测试一切正常部署到内网服务器上就报错。最后排查发现那台服务器的系统时间快了 8 分钟正好卡在容差边缘。建议生产环境务必配置 NTP 时间同步排查问题时先把客户端时间与服务端时间对齐排除时间因素再查签名逻辑。5.2 重定向导致认证信息丢失这是HttpClient.Redirect相关的大坑。当服务端返回 302/307 重定向时HttpClient 默认不会自动携带原始的 Authorization 头到重定向目标因为在跨主机重定向时携带凭证是安全隐患。但如果你把重定向目标配成了一个内网地址或者同域地址就可能因为签名头被丢弃而得到 403。我在对接某个私有化部署的对象存储网关时就遇到了这个场景。网关的桶地址会重定向到实际存储节点的地址而 HttpClient 自动跟随重定向后没有带上签名头。解决办法有两个关闭自动重定向捕获 3xx 响应后手动解析Location头重新签名再发起请求。如果重定向目标是同域名可以自定义重定向处理逻辑把原始请求头手动复制过去。HttpClient client HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.NEVER) .build();关闭自动重定向意味着你需要自己处理重定向响应。虽然多写几行代码但签名场景下这是最可控的方案。5.3 Header 顺序与大小写问题HTTP Header 名义上是大小写不敏感的但签名计算时要求把 header 名统一转成小写。我遇到过对接方要求host必须小写、值里不能有额外空格的情况。如果你的请求头是通过HttpRequest.Builder逐个添加的HttpClient 可能会对 header 做某种规范化导致你签名用的字符串和实际发送的 head 不完全一致。稳妥的做法是签名时用自己构造的规范化 header 字符串发送时也以同样的值填充。不要把request.headers()里读到的顺序当作签名顺序因为 HttpRequest 不保证 header 的迭代顺序。5.4 Query 参数排序和编码不一致服务端计算签名时会把查询参数按 key 的字节序排序然后拼接。如果你发送的 URL 里查询参数顺序是?b2a1而签名时按a1b2计算的服务端会先对参数排序再比对所以理论上顺序不一致也能通过。但有些服务端实现只做了严格比对不会帮你重新排序这种情况下就会报签名错误。更常见的问题是参数编码不一致。比如和%20表示空格%2F和/在某些参数里等价但签名协议要求统一使用规范编码。我的建议是所有查询参数在拼接 URL 之前就用规范方式编码好不要让 HttpClient 再对 URL 做二次编码。这里还有一个容易被忽略的细节HttpRequest构造时传入的URI要求必须已经完成编码。Java 的URI.create()不会自动转义空格和中文你需要用new URI(scheme, host, path, query, null)这种构造方式或者手工编码后再传入。6. 我在踩坑之后的一些心得回到最初的问题JDK 17 HttpClient 能不能胜任 SigV4 签名的集成我的答案是完全可以而且很顺手。原生 HttpClient 的构建器模式让请求头注入变得很直观异步 API 与CompletableFuture的配合也很顺畅对付签名请求这种场景绰绰有余。一开始我踩过的最大坑就是低估了 HTTP 协议和签名协议之间的格式敏感程度。写代码前一定要把规范里每个字段的格式要求读清楚尤其是编码规则、时间格式、header 大小写这些细节。签名报错时与其反复看代码不如先手动把 Canonical Request 和 StringToSign 打出来逐个字段核对往往很快就能定位问题。如果后续项目里还要支持华为云 OBS、阿里云 OSS、腾讯云 COS 这些国内厂商的签名协议你会发现它们的签名算法基本都脱胎于 SigV4理解了这一套其他协议里的概念自然就通了。签名方案本身并不复杂复杂的是它和 HTTP 的每个细节耦合得很深。把这套流程吃透之后遇到任何需要签名的接口心里都会踏实很多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询