InvalidSignatureError on LINE webhook — 7 root causes and fixes

You wired up the webhook. The LINE Developer Console "Verify" button still glows red. Your logs say InvalidSignatureError and you've triple-checked the secret.

This post is the decision tree I wish I had the first time I shipped a LINE bot. Seven distinct root causes, each with a one-line symptom and the actual fix — not just "check your secret."

Need to see the math? Paste your real body, header, and secret into the signature validator — the side-by-side diff shows you exactly where your computed signature diverges from the one LINE sent.


How the signature actually works

LINE computes:

HMAC_SHA256(channelSecret, rawRequestBody) → base64

…and puts that base64 string in the X-Line-Signature header. Your server has to compute the same thing over the same bytes with the same secret, and the result has to be byte-equal to the header value. If anything in that pipeline drifts — encoding, framework, proxy — you get a mismatch.

So when validation fails, the failure is almost always in one of these spots:

  1. The credential you used isn't the channel secret
  2. The bytes you signed aren't the bytes LINE signed
  3. The encoding of your computed value isn't base64

The 7 causes below all map back to one of those three.


1. Wrong credential: Channel Secret vs Channel Access Token

Most common cause. The LINE Developer Console gives you two long strings:

Where to findWhat it isUsed for
Channel → Basic settings → Channel secret32 hex charsSignature verification
Channel → Messaging API → Channel access tokenvery long opaque stringAPI calls (reply/push)

They look similar enough that copy-pasting the wrong one is the #1 way to get InvalidSignatureError. The access token is for sending messages; the secret is for verifying incoming ones.

Fix: Use the Channel Secret under Basic settings. If your secret string is much longer than 32 chars, you grabbed the wrong one.


2. Express + body-parser consumed the raw bytes

// ❌ broken
app.use(express.json());
app.post("/webhook", line.middleware(config), handler);

express.json() reads the request stream and replaces req.body with the parsed object. By the time line.middleware() runs, the raw bytes are gone — it can't compute HMAC over a JavaScript object.

Fix: put line.middleware before any JSON parser, or scope the parser to other routes:

// ✅ works
app.post("/webhook", line.middleware(config), handler);
app.use(express.json()); // for everything else

If you have to parse JSON yourself before the LINE middleware, capture the raw body:

app.use(express.raw({ type: "application/json" }));
app.post("/webhook", (req, res) => {
  const rawBody = req.body; // Buffer
  // verify HMAC against rawBody, then JSON.parse(rawBody.toString())
});

3. FastAPI auto-parsed the body

Same shape of bug, different framework:

# ❌ FastAPI has already parsed body into JSON by the time you read it
@app.post("/webhook")
async def webhook(payload: dict):
    verify_signature(payload, ...)  # payload is already a dict — bytes gone

Fix: read request.body() as bytes before doing anything else:

@app.post("/webhook")
async def webhook(request: Request):
    body_bytes = await request.body()
    signature = request.headers["x-line-signature"]
    verify_signature(body_bytes, signature, CHANNEL_SECRET)
    payload = json.loads(body_bytes)  # parse AFTER verification

The official line-bot-sdk-python has WebhookParser.parse(body, signature) that does this correctly — just make sure you feed it raw bytes, not await request.json().


4. Spring Boot filter chain consumed the stream

HttpServletRequest's input stream can only be read once. If any filter, interceptor, or @RequestBody deserializer reads the body before your signature check, the stream is exhausted and you'll get an empty payload.

A common-but-broken fix is ContentCachingRequestWrapper — its getContentAsByteArray() only returns bytes after the wrapped stream has been consumed downstream, so reading it in your filter first gives you an empty array.

Fix: use a custom wrapper that caches the body on construction and re-serves it from the cache:

public class CachedBodyRequestWrapper extends HttpServletRequestWrapper {
    private final byte[] body;

    public CachedBodyRequestWrapper(HttpServletRequest req) throws IOException {
        super(req);
        this.body = StreamUtils.copyToByteArray(req.getInputStream());
    }

    public byte[] getBody() { return body; }

    @Override
    public ServletInputStream getInputStream() {
        var bais = new ByteArrayInputStream(body);
        return new ServletInputStream() {
            @Override public int read() { return bais.read(); }
            @Override public boolean isFinished() { return bais.available() == 0; }
            @Override public boolean isReady() { return true; }
            @Override public void setReadListener(ReadListener l) {}
        };
    }
}

Register it early in the chain so every downstream component reads from the cache:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CachingFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException {
        var wrapped = new CachedBodyRequestWrapper(req);
        // verify HMAC against wrapped.getBody() here
        chain.doFilter(wrapped, res);
    }
}

Now your signature check sees the exact bytes and @RequestBody deserializers still work.


5. Base64 vs hex confusion

HMAC_SHA256.digest() returns raw bytes. You have to encode them — and LINE expects base64, not hex.

// ❌ comparing hex against a base64 header
const computed = crypto.createHmac("sha256", secret).update(body).digest("hex");
return computed === receivedSignature; // always false
// ✅ base64
const computed = crypto.createHmac("sha256", secret).update(body).digest("base64");

Quick check: a real X-Line-Signature is ~44 characters and ends in =. If your computed value is 64 lowercase hex chars, you're in this trap.


6. Trailing newline / re-stringified body

The body has to be byte-identical to what LINE signed. These all silently break that:

Fix: treat the body as opaque bytes from the moment it lands. Never round-trip through a parser before signing. If you need to log, log the byte length and a short hex prefix — not the formatted JSON.


7. A proxy or CDN modified the request body

You verified the credential, the framework is fine, you're computing base64 — and it still fails in production. Suspect the network layer.

Cloudflare, AWS ALB, nginx with client_body_buffer_size quirks, ngrok with --host-header=rewrite — anything between LINE and your handler that touches the body will invalidate the signature. Watch for:

Fix: log the raw body bytes (hex-prefix) at the edge and at your origin and diff them. If they differ, the network is the culprit — fix at the proxy layer, not in your code. As a quick check, set up a direct test bypassing the proxy and confirm the signature validates there.


Still stuck? A 30-second checklist

If you've been through the seven above and still seeing red, run through this:

If the validator on this site says MATCH but your server says mismatch, the bug is in your server's body-handling — not in the math.

If the validator says mismatch with the values from your server logs, those values disagree with what LINE actually sent — the bug is upstream (network or LINE config).

That split tells you where to look next.


Caught a mistake or have a cause I missed? Open an issue on the validator's repo — this post lives in the same codebase.