Go 后端接 Web3 钱包登录,标准流程只有四步:
- 后端下发 nonce
- 前端钱包用
personal_sign签 “Login\nNonce: xxx” - 后端 recover 出地址,比对是不是同一个
- 通过后签发 JWT / session
省心的做法是前端签名 + 后端验签,别在后端搞私钥。
生成 nonce
一次性、5 分钟过期、写 Redis:
package auth
import (
"crypto/rand"
"encoding/hex"
)
func GenerateNonce() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
Redis key:wallet:nonce:0xabc... → <nonce>,TTL 5 分钟。
前端签名
const message = `Login\nNonce: ${nonce}`;
const signature = await ethereum.request({
method: "personal_sign",
params: [message, address],
});
要点:
- 用
personal_sign,不要自己算 hash - 消息用明文发给 MetaMask,它会自动加 EIP-191 前缀
Go 侧验签
package auth
import (
"fmt"
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
)
func VerifySignature(address, message, sigHex string) bool {
sigHex = strings.TrimPrefix(sigHex, "0x")
sig := common.FromHex(sigHex)
if len(sig) != 65 {
return false
}
// 关键点 1:v 值处理(MetaMask 返回 27/28,crypto 库要 0/1)
if sig[64] != 27 && sig[64] != 28 {
return false
}
sig[64] -= 27
// 关键点 2:EIP-191 前缀
prefixed := fmt.Sprintf(
"\x19Ethereum Signed Message:\n%d%s",
len(message), message,
)
hash := crypto.Keccak256Hash([]byte(prefixed))
pubKey, err := crypto.SigToPub(hash.Bytes(), sig)
if err != nil {
return false
}
recovered := crypto.PubkeyToAddress(*pubKey)
return strings.EqualFold(recovered.Hex(), address)
}
登录 handler
type LoginReq struct {
Address string `json:"address"`
Signature string `json:"signature"`
}
func Login(c *gin.Context) {
var req LoginReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "bad request"})
return
}
key := "wallet:nonce:" + strings.ToLower(req.Address)
nonce, err := redisClient.Get(ctx, key).Result()
if err != nil {
c.JSON(401, gin.H{"error": "nonce expired"})
return
}
message := "Login\nNonce: " + nonce
if !VerifySignature(req.Address, message, req.Signature) {
c.JSON(401, gin.H{"error": "invalid signature"})
return
}
// 防重放:立刻删掉 nonce
redisClient.Del(ctx, key)
token, _ := GenerateJWT(req.Address)
c.JSON(200, gin.H{"token": token})
}
三个必踩的坑
1. 少了 EIP-191 前缀
MetaMask 的 personal_sign 会自动加:
"\x19Ethereum Signed Message:\n" + len(msg) + msg
后端验签必须手动加回这个前缀再算 keccak。少了直接验不过。
2. v 值 27/28 vs 0/1
MetaMask 返回的签名最后一个字节(v)是 27 或 28(EIP-155 前的格式)。go-ethereum 的 crypto.SigToPub 要求是 0 或 1。必须减 27。
3. 忘了防重放
nonce 验完不删,攻击者抓包重放就能永远登进来。验签成功后立刻 redis.Del。
建议直接上 SIWE
Sign-In with Ethereum(EIP-4361)是现在的标准,消息格式包括域名、URI、Chain ID、Issued At 等,钱包会更友好地显示:
example.com wants you to sign in with your Ethereum account:
0xabc...123
URI: https://example.com
Version: 1
Chain ID: 1
Nonce: 8a2c...
Issued At: 2026-05-10T16:31:04Z
Go 侧现成库:
go get github.com/spruceid/siwe-go
import siwe "github.com/spruceid/siwe-go"
msg, err := siwe.ParseMessage(rawMessage)
if err != nil { ... }
if _, err := msg.Verify(signature, &domain, &nonce, nil); err != nil {
// 验签失败
}
SIWE 帮你处理消息构造、时效、chain id、防重放,比手写靠谱。
前端推荐栈
- wagmi + viem:现在 EVM dApp 的事实标准
- 直接
useSignMessage,签名一行搞定 - 不用自己处理
window.ethereum
一句话总结
Web3 登录 = nonce + personal_sign + Go recover 比地址。三个坑:加 EIP-191 前缀、v 值减 27、nonce 用完立刻删。新项目直接上 SIWE,别自己拼消息格式。
