← 전체 글로 돌아가기

Next.js

Next.js Route Handler에서 웹훅 본문을 먼저 읽으면 서명 검증이 깨지는 이유

웹훅 JSON을 먼저 파싱한 뒤 서명 검증이 실패했던 상황에서, raw body를 한 번만 읽는 방식으로 고친 기록입니다.

실패한 요청은 JSON 모양이 멀쩡했다

결제 웹훅을 붙인 뒤 request.json()으로 이벤트를 출력해 보니 데이터는 정상처럼 보였다. 그런데 공급자 SDK의 서명 검증은 계속 실패했다. 처음에는 비밀 키를 잘못 넣었다고 생각했지만, 로컬과 배포 서버 모두 같은 결과였다.

헷갈린 지점은 Request 본문이 한 번 읽히는 스트림이라는 사실이었다. 디버깅용으로 JSON을 먼저 꺼낸 순간, 검증 함수에 넘길 원문은 비어 있었다. JSON 객체가 같아 보여도 공백, 줄바꿈, 인코딩을 포함한 바이트열이 달라지면 서명은 맞지 않는다.

본문은 text로 한 번만 받는다

Route Handler에서는 원문을 먼저 받고 그 값으로 검증한 뒤에만 이벤트를 다뤘다.

import { headers } from 'next/headers'

export async function POST(request: Request) {
  const rawBody = await request.text()
  const signature = (await headers()).get('x-provider-signature')

  if (!signature) {
    return new Response('missing signature', { status: 400 })
  }

  try {
    const event = verifyWebhook(rawBody, signature, process.env.WEBHOOK_SECRET!)
    await handleEvent(event)
    return Response.json({ received: true })
  } catch (error) {
    console.error('webhook verification failed', error)
    return new Response('invalid signature', { status: 400 })
  }
}

여기서 rawBodyJSON.parse()한 문자열로 다시 만들지 않는 것이 기준이다. SDK가 Request 자체나 Buffer를 요구한다면 문서에 맞춰 그 형태를 그대로 전달한다. Pages Router의 body parser 설정을 복사해 Route Handler에 넣는 것도 해결책이 아니었다. 두 환경의 요청 처리 방식이 다르다.

재현할 때 확인한 순서

테스트 이벤트를 보낸 뒤 다음만 로그에 남겼다. 본문 전체나 서명 값은 운영 로그에 남기지 않았다.

console.info({
  contentType: request.headers.get('content-type'),
  hasSignature: Boolean(signature),
  rawLength: rawBody.length,
})

rawLength가 0이면 이미 다른 코드가 본문을 소비했을 가능성이 크다. 이어서 배포 환경의 WEBHOOK_SECRET 존재 여부와, 웹훅 공급자가 서명하는 헤더 이름을 확인했다. 프록시를 지난다고 본문이 바뀌는 경우보다 이 두 가지가 더 흔했다.

다음 배포 전 점검

  • 검증 전에 request.json()이나 request.formData()를 호출하지 않는다.
  • 원문과 서명은 민감 정보이므로 로그에 출력하지 않는다.
  • 실패 응답은 400으로 분명히 보내고, 재시도 정책을 공급자 쪽에서 확인한다.
  • 테스트 이벤트와 실제 이벤트를 각각 한 번 검증한다.

웹훅은 “파싱한 데이터”가 아니라 “도착한 원문”을 검증한다는 기준을 잡고 나니, 디버깅 코드도 훨씬 단순해졌다.