서명 검증
서명 규칙
| 항목 | 값 |
|---|---|
| 서명 대상 | 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