Skip to Content
개발자웹훅서명 검증

서명 검증

서명 규칙

항목값
서명 대상webhook-id + ”.” + webhook-timestamp + ”.” + rawBody
알고리즘HMAC-SHA256
키엔드포인트의 whsec_ 뒤 문자열을 base64 디코드한 바이트예요.
헤더 형식webhook-signature에는 v1,<sig> 형식의 서명을 담아요.
키 회전24시간 유예 동안 v1,<새 서명> v1,<이전 서명>처럼 새 키와 이전 키의 서명 두 개가 올 수 있어요. 어느 한 서명이라도 보유한 키와 일치하면 통과예요.
시각 검증webhook-timestamp가 현재 시각에서 5분 이내인지 확인해요.

콘솔 재전송은 같은 webhook-id와 새 webhook-timestamp를 사용하며, 새 발송 시각으로 서명을 다시 계산해요.

JSON 파싱·재직렬화 전에 원문 바이트를 보관해 서명을 검증해요.

Node.js 패키지 사용

import { Webhook } from 'standardwebhooks' const webhook = new Webhook(process.env.PAYRI_WEBHOOK_SECRET) // whsec_... const event = webhook.verify(req.rawBody, { 'webhook-id': req.get('webhook-id'), 'webhook-timestamp': req.get('webhook-timestamp'), 'webhook-signature': req.get('webhook-signature') })

Node.js 직접 구현

import express from 'express' import crypto from 'node:crypto' const app = express() function verifyWebhook({ id, timestamp, signatureHeader, rawBody, secrets }) { const sentAt = Number(timestamp) if (!Number.isSafeInteger(sentAt) || Math.abs(Math.floor(Date.now() / 1000) - sentAt) > 300) { return false } const signedBytes = Buffer.concat([ Buffer.from(id + '.' + timestamp + '.'), rawBody ]) const candidates = (signatureHeader ?? '').trim().split(/\s+/).filter(Boolean) for (const item of candidates) { const comma = item.indexOf(',') if (comma < 0 || item.slice(0, comma) !== 'v1') continue const supplied = Buffer.from(item.slice(comma + 1), 'base64') for (const secret of secrets.filter(Boolean)) { if (!secret.startsWith('whsec_')) continue const key = Buffer.from(secret.slice('whsec_'.length), 'base64') const expected = crypto.createHmac('sha256', key).update(signedBytes).digest() if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) { return true } } } return false } // Express는 JSON 파싱 전에 원문 바이트를 보관해요. app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = Buffer.from(buf) } })) app.post('/webhook', (req, res) => { const valid = verifyWebhook({ id: req.get('webhook-id') ?? '', timestamp: req.get('webhook-timestamp') ?? '', signatureHeader: req.get('webhook-signature') ?? '', rawBody: req.rawBody, secrets: [ process.env.PAYRI_WEBHOOK_SECRET, process.env.PAYRI_WEBHOOK_SECRET_PREVIOUS ] }) if (!valid) return res.sendStatus(400) // webhook-id를 저장해 중복 이벤트를 걸러낸 뒤 처리해요. return res.sendStatus(202) })
Last updated on