Skip to content

CWE-347: Improper Verification of Cryptographic Signature - Go

Overview

Go services most often verify JWTs with github.com/golang-jwt/jwt (v4 or v5). The library asks the caller to supply a jwt.Keyfunc that receives the parsed-but-not-yet-verified token and returns the key material to verify it with. That design puts algorithm selection in application code: a Keyfunc that returns key material without looking at token.Method has let the token's own header decide what the key will be used for.

Be exact about what that costs on a current library, because the shape most often quoted no longer fires. Measured on golang-jwt/jwt/v5 5.3.1, the textbook forgery - re-sign a legitimate RS256 token as HS256 using the server's RSA public key as the HMAC secret - is refused by the library itself with key is of invalid type: HMAC verify expects []byte, whether the secret is the PEM text or the SPKI bytes, and whether or not the keyfunc checked anything. alg: none is refused too, unless the keyfunc explicitly returns the jwt.UnsafeAllowNoneSignatureType sentinel. What is still wide open is cross-algorithm acceptance within a family: an RS512 token verifies against a deployment that only ever issues RS256, so "the signature verified" and "this token was issued the way we issue tokens" remain different statements. That gap survives the type assertion most pages recommend, because *jwt.SigningMethodRSA is the type of RS256, RS384 and RS512 alike.

A second common source of CWE-347 findings in Go is webhook and API signature verification implemented with crypto/hmac or raw hashing, then compared with bytes.Equal() or a manual ==/byte-loop comparison instead of a constant-time function. Neither bytes.Equal() nor a hand-written loop guarantees constant-time behavior; both can return as soon as a mismatch is found, leaking timing information.

The safe replacements are: assert token.Method against the expected type inside every Keyfunc so the key never reaches the wrong algorithm family, pass jwt.WithValidMethods(...) to pin the exact algorithm, and use hmac.Equal() (crypto/hmac) or subtle.ConstantTimeCompare() (crypto/subtle) for any raw signature or HMAC comparison.

Common Vulnerable Patterns

Keyfunc Without a Method Type Check

import (
    "github.com/golang-jwt/jwt/v5"
)

// VULNERABLE - returns the RSA public key regardless of which algorithm
// the token header claims, and nothing at the parser level pins one either
keyFunc := func(token *jwt.Token) (interface{}, error) {
    return rsaPublicKey, nil
}

token, err := jwt.Parse(tokenString, keyFunc)
if err != nil || !token.Valid {
    return fmt.Errorf("invalid token")
}

// Attack: take a legitimate RS256 token, change the header to {"alg":"RS512"},
// and re-sign it with the same key. jwt.Parse accepts it, because rsaPublicKey
// verifies RS256, RS384 and RS512 alike and neither the keyfunc nor the parser
// said which one this deployment issues.

Why this is vulnerable: returning the same key variable regardless of token.Method (or branching on token.Header["alg"], which is attacker-controlled) means the algorithm the token is verified with is chosen by the token, not by the server. Measured on golang-jwt v5.3.1, that no longer reaches across key types - the library refuses to use an *rsa.PublicKey as an HMAC secret, so the RS256-as-HS256 forgery fails with key is of invalid type: HMAC verify expects []byte even here. It does still reach across the algorithms one key can satisfy, which is enough to accept a token your issuer would never have minted, and it is the shape a scanner rule written for the older attack will not describe correctly.

Branching on token.Header["alg"] to Choose the Key

// VULNERABLE - the unverified header selects which key material is returned
keyFunc := func(token *jwt.Token) (interface{}, error) {
    if token.Header["alg"] == "HS256" {
        return sharedHMACSecret, nil
    }
    return rsaPublicKey, nil
}

Why this is vulnerable: this is the form of the attack that still works on a current golang-jwt, because the application performs the key-type conversion the library refuses to. token.Header is the raw, unverified header map, so an attacker who knows or can guess the deployment holds a symmetric secret anywhere in this branch tree only has to set alg to reach it. The library's type check protects you from handing an RSA key to an HMAC verifier; it cannot protect you from a keyfunc that fetches an actual HMAC key when asked to.

Non-Constant-Time Comparison for Webhook HMAC

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
)

// VULNERABLE - bytes.Equal() is not constant-time; it can return as soon
// as it finds the first differing byte
mac := hmac.New(sha256.New, webhookSecret)
mac.Write(requestBody)
expected := mac.Sum(nil)

if !bytes.Equal(expected, provided) {
    return fmt.Errorf("invalid webhook signature")
}

Why this is vulnerable: bytes.Equal() is optimized for correctness and speed, not for hiding timing differences. It returns as soon as it finds a difference, so the time it takes depends on how much of the submitted signature matched, even though the code "looks" like it is doing the right comparison.

How much that leaks is smaller than the usual description suggests, because the comparison runs a machine word at a time rather than a byte. Measured on go1.25.5 with 64-byte values, the whole spread between a mismatch in the first byte and one in the last is around 1 ns, and the positions in between do not order themselves by index at all. What is cleanly separable is a length mismatch: 32 bytes against 64 returns in half the time of any equal-length comparison. Fix it regardless - the block width is an implementation detail that moves with the architecture and the toolchain, a same-host or co-tenant attacker measures far more finely than a remote one, and subtle.ConstantTimeCompare costs one call. CWE-208 has the argument in full.

Secure Patterns

Type-Assert the Signing Method Inside Keyfunc

import (
    "fmt"

    "github.com/golang-jwt/jwt/v5"
)

// SECURE - rejects any signing method other than RSA before returning key material
keyFunc := func(token *jwt.Token) (interface{}, error) {
    if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
        return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
    }
    return rsaPublicKey, nil
}

token, err := jwt.Parse(
    tokenString,
    keyFunc,
    jwt.WithValidMethods([]string{"RS256"}), // pins the exact algorithm - not optional
)
if err != nil || !token.Valid {
    return fmt.Errorf("invalid token: %w", err)
}

Why this works: the two checks cover different things, and the common habit of calling the second one "defense in depth" gets it backwards.

  • The type assertion decides the family. token.Method is populated by the library from the parsed header before the keyfunc runs, so an HS256 header produces a *jwt.SigningMethodHMAC and fails the assertion before any key material is returned. That is what stops a keyfunc from handing an RSA key to a symmetric verifier, and it also refuses PS256, whose method is the distinct *jwt.SigningMethodRSAPSS.
  • jwt.WithValidMethods([]string{"RS256"}) decides the algorithm. It is the only one of the two that does: *jwt.SigningMethodRSA is the type of jwt.SigningMethodRS256, RS384 and RS512, so an RS512 token passes the assertion. Measured on v5.3.1, the keyfunc above accepts an RS512 token on its own and refuses it once WithValidMethods is present.

Neither is redundant. Drop the assertion and a keyfunc with a symmetric branch becomes reachable again on any call site that forgets the parser option; drop WithValidMethods and the deployment silently accepts algorithms its issuer never uses.

Resolve Keys by kid From a Trusted Keystore

// SECURE - kid is used only as a lookup key into a trusted, server-side store;
// the algorithm expectation never comes from the token itself
keyFunc := func(token *jwt.Token) (interface{}, error) {
    if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
        return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
    }
    kid, ok := token.Header["kid"].(string)
    if !ok {
        return nil, fmt.Errorf("missing kid")
    }
    return trustedKeyStore.RSAPublicKey(kid) // returns an error for unknown kid
}

Why this works: kid is used strictly as an index into a keystore the server populated ahead of time from a trusted source (a pinned JWKS fetch or configuration). The method-type check still runs first, so even a kid that happens to collide with an HMAC secret's identifier cannot be returned to satisfy an HS256 header, because that header never passes the type assertion. Parse this keyfunc with the same jwt.WithValidMethods([]string{"RS256"}) as the pattern above - the assertion alone still admits RS384 and RS512.

kid is the only header parameter this keyfunc reads, and that is the boundary worth holding. jku, x5u, jwk and x5c invite the same lookup and are not the same thing: they name the source of the key rather than an entry in a source you already trust, so honouring one lets the sender supply a key pair they generated. jku and x5u additionally turn the verification path into an outbound HTTP request to an attacker-chosen URL (CWE-918). Read kid; ignore the rest.

Constant-Time Comparison for HMAC/Signature Bytes

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
)

// SECURE - webhook HMAC-SHA256 verification with constant-time comparison
mac := hmac.New(sha256.New, webhookSecret)
mac.Write(requestBody)
expected := mac.Sum(nil)

provided, err := hex.DecodeString(signatureHeader)
if err != nil || !hmac.Equal(expected, provided) {
    return fmt.Errorf("invalid webhook signature")
}

Why this works: hmac.Equal() compares equal-length slices in constant time regardless of where they first differ, which removes the content-dependent signal, and it handles a length mismatch by returning false rather than panicking. crypto/subtle.ConstantTimeCompare() gives the same property for non-HMAC byte comparisons (raw signature bytes, tokens, and so on) and is the right choice when crypto/hmac is not otherwise in use - hmac.Equal is a one-line wrapper around it.

Neither hides the length, and the wrapper is why: ConstantTimeCompare returns 0 as soon as it sees that the two slices are different sizes. Measured on go1.25.5 against a 32-byte MAC, a 16-byte candidate returns in 2.2 ns where any equal-length comparison takes about 11 ns. For an HMAC that is harmless - the digest length comes from the algorithm and is not a secret - but do not carry the guarantee over to a value whose length is sensitive. CWE-385 has the length behaviour for every language in one table.

Framework-Specific Guidance

Reading the Raw Request Body Before Verification (net/http)

import (
    "encoding/json"
    "io"
    "net/http"
)

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }

    // SECURE - verify against the exact bytes received, before any JSON
    // decoding or struct binding that could normalize whitespace or key order
    if !verifyWebhookSignature(body, r.Header.Get("X-Signature"), webhookSecret) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    var payload WebhookPayload
    if err := json.Unmarshal(body, &payload); err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }
    // process payload
}

Why this works: HMAC verification is only meaningful against the exact bytes the sender signed. Decoding the body into a struct first and re-serializing it (or verifying against a re-encoded copy) can produce bytes that differ from the original payload in whitespace, field order, or numeric formatting, causing either false rejections or, worse, a mismatch between what was verified and what is actually processed. Reading and verifying the raw body before any decoding keeps those in sync.

Considerations

This is one of the few findings that is almost never a false positive. For most weaknesses the first question is whether the value is security-relevant; here, if a signature is being checked at all, something is trusting the result. The narrow exception is data that never crossed a trust boundary - a token your own process minted, held in memory, and verified moments later. If the token arrived over the network, the check matters.

Decide where verification happens, and whether once is enough. A gateway that verifies tokens before forwarding lets backend services skip the work, which is efficient and fine until one service becomes reachable another way - an internal caller, a service mesh retry, a debugging port. Verifying again in the service costs little and does not depend on network topology staying as drawn. If you do rely on the gateway, make it impossible to bypass rather than merely inconvenient.

Symmetric algorithms give every verifier the power to mint. HS256 uses one shared secret, so any service holding it to check tokens can also issue them. With three services and one secret you have three places a forged administrator token can come from. RS256 and EdDSA split that: the issuer holds the private key, verifiers hold only the public one. If more than one service verifies, that separation is worth the extra key management.

Set expiry expectations explicitly. golang-jwt v5 validates exp when the claim is present but does not require it, so a token that omits exp passes. Add jwt.WithExpirationRequired(), and use jwt.WithLeeway() for a deliberate, small skew allowance rather than leaving it implicit.

Key rotation needs a cache policy decided in advance. Resolving keys by kid from a JWKS endpoint means an outbound fetch on the verification path. Cache too briefly and every request becomes a network call, so an issuer outage takes your authentication down with it; cache too long and a rotated-away key stays trusted. Cache by kid with a refresh on unknown values, plus a floor on how often that refresh can fire, so an attacker cannot drive fetches by sending tokens with random kid values.

Expiry is not revocation. Signature verification proves a token was issued and unmodified; it says nothing about whether the account was disabled a minute ago. Short lifetimes narrow that window and cost a refresh round trip; a revocation list closes it and costs a lookup on every request. Which you need depends on how quickly access must actually stop - "immediately" and "within fifteen minutes" are different systems.

Testing

  • Normal: a legitimately issued RS256 token from the real issuer validates and its claims parse correctly.
  • Boundary: a token signed by a kid not present in the trusted keystore, and a webhook signature header with an odd-length or non-hex value, are both rejected without panicking.
  • Malicious - cross-algorithm: re-sign a valid RS256 token as RS512 with the same key; jwt.Parse must return signing method RS512 is invalid. This is the assertion that separates a working fix from a decorative one - the keyfunc's type assertion passes an RS512 token, so only jwt.WithValidMethods makes this test go red-to-green.
  • Malicious - algorithm confusion: re-sign a valid RS256 token as HS256 using the server's known RSA public key as the HMAC secret; jwt.Parse must return an error. On golang-jwt v5 this test passes before the fix as well, because the library refuses the key type - it confirms the library's behaviour, not your keyfunc's. If your keyfunc has any branch that returns a []byte secret, run this test against that secret instead.
  • Malicious - alg=none: submit a token with header {"alg":"none"} and an empty signature segment; parsing must fail. Which message you see says which check caught it - measured on v5.3.1, jwt.WithValidMethods refuses it first with signing method none is invalid, and a parser without that option refuses it with the library's own 'none' signature type is not allowed.
  • Malicious - tampered webhook payload: flip a single byte in the request body while keeping the original signature header; verification must fail.

Common Pitfalls

  • Checking token.Header["alg"] as a string instead of working from token.Method: token.Header["alg"] is just the unverified header value re-exposed as a map entry; comparing it to "RS256" as a string is easy to get subtly wrong (case sensitivity, extra whitespace) and easy to bypass if the check is skipped on one code path. token.Method is the SigningMethod the library resolved and will actually use, so a decision made from it stays consistent with what the library does next.
  • Adding jwt.WithValidMethods but leaving the keyfunc unchanged: a keyfunc that still returns key material unconditionally means any code path that constructs a parser without that option (a second endpoint, a background job, a test helper promoted to production) is unprotected. Fix the keyfunc itself, not just the parser options at one call site.
  • Asserting *jwt.SigningMethodRSA and treating the algorithm as pinned: that is the family type, shared by RS256, RS384 and RS512, so the assertion that correctly refuses HS256 waves an RS512 token through. Assert the concrete value (token.Method != jwt.SigningMethodRS256) or, better, pass jwt.WithValidMethods and keep the assertion for the family. The same applies to *jwt.SigningMethodECDSA (ES256/384/512) and *jwt.SigningMethodHMAC (HS256/384/512) - the last of which is where an HS512 token gets accepted by an HS256 deployment.
  • Using bytes.Equal() because it "returns a bool like a comparison should": bytes.Equal() is the idiomatic way to compare byte slices for correctness, but correctness and constant-time behavior are different properties. Grep for bytes.Equal( and hmac.New( together to catch this pattern.

Dependencies and Installation

  • github.com/golang-jwt/jwt/v5 (the maintained successor to dgrijalva/jwt-go, which is unmaintained and should be replaced if still in use) - keep at a current version via go get -u.
  • crypto/hmac, crypto/subtle, and crypto/sha256 are part of the Go standard library; no additional dependency is needed for constant-time comparison.

Migration Considerations

Adding jwt.WithValidMethods and a strict method assertion will reject any token signed with an algorithm the deployment previously accepted implicitly (for example, a service that unintentionally accepted both RS256 and HS256 tokens). Confirm which algorithms legitimate issuers actually use before narrowing the allowlist, and coordinate the rollout with token issuers if any are still using a deprecated algorithm.

Additional Resources