HMAC이란 무엇인가: 비밀 키로 메시지를 인증하는 원리

HMAC이란 무엇인가: 비밀 키로 메시지를 인증하는 원리

결제 서비스가 우리 서버로 다음과 같은 웹훅(webhook)을 보냈다고 해볼까요?

결제 완료 웹훅
{
  "eventId": "evt_1234",
  "type": "payment.completed",
  "amount": 50000
}

HTTPS로 받았으니 무조건 믿고 결제 완료 처리를 해도 될까요? HTTPS는 전송 구간을 보호하지만, 이 요청이 정말 결제 서비스가 만든 것인지 애플리케이션이 확인할 방법은 따로 필요합니다. 공격자가 우리 웹훅 주소를 알아내고 가짜 요청을 직접 보낼 수도 있으니까요.

메시지의 SHA-256 해시를 함께 받으면 해결될 것 같지만, 공격자는 가짜 메시지의 해시도 다시 계산할 수 있습니다. 누구나 계산할 수 있는 해시만으로는 메시지를 만든 상대를 가려낼 수 없는 셈이죠.

이때 사용하는 도구가 해시 기반 메시지 인증 코드(Hash-based Message Authentication Code, HMAC)입니다. HMAC은 암호학적 해시 함수에 비밀 키를 결합해, 키를 아는 쪽만 만들 수 있는 인증 태그(authentication tag)를 생성합니다. 이번 글에서는 HMAC이 무엇을 보장하는지부터 웹훅을 안전하게 검증하는 방법까지 알아보겠습니다.

해시만으로는 보낸 사람을 알 수 없습니다

일반 해시는 메시지만 입력으로 받습니다. 같은 메시지를 가진 사람은 누구나 같은 해시값을 계산할 수 있죠.

일반 해시
hash(메시지) ──▶ 해시값

파일과 해시값을 서로 다른 신뢰 경로에서 받았다면 체크섬으로 무결성을 비교할 수 있습니다. HTML의 integrity 속성에 기대한 해시를 적어 두는 하위 리소스 무결성(Subresource Integrity, SRI)도 같은 원리를 이용합니다. 하지만 메시지와 해시값이 같은 요청에 담겨 온다면 공격자가 둘을 함께 바꿀 수 있어요.

HMAC은 메시지뿐 아니라 양쪽이 미리 공유한 비밀 키(secret key)도 입력으로 받습니다.

HMAC
HMAC(비밀 키, 메시지) ──▶ 인증 태그

보내는 쪽은 비밀 키와 메시지로 인증 태그를 만들고, 받는 쪽은 같은 키와 받은 메시지로 태그를 검증합니다. 공격자가 메시지와 인증 태그를 가로채더라도 비밀 키를 모르면 바뀐 메시지에 맞는 새 태그를 만들기 어렵습니다.

받는 쪽은 다음 두 가지를 확인할 수 있습니다.

  • 무결성(integrity): 인증 태그를 만든 뒤 메시지가 바뀌지 않았습니다.
  • 진위(authenticity): 공유 비밀 키를 아는 누군가가 인증 태그를 만들었습니다.

여기서 “누군가”라는 표현이 중요합니다. 같은 키를 가진 참여자는 누구나 태그를 생성할 수 있으므로, HMAC만으로 특정 참여자가 만들었다는 사실을 제삼자에게 증명할 수는 없습니다.

HMAC은 암호화나 디지털 서명이 아닙니다

HMAC은 메시지를 숨기지 않습니다. 메시지와 인증 태그를 함께 보내면 둘 다 통신 상대나 중간 시스템에서 볼 수 있어요. 내용까지 감춰야 한다면 HMAC과 별개로 암호화가 필요합니다.

디지털 서명과도 키 구조가 다릅니다. HMAC은 생성과 검증에 같은 비밀 키를 사용하는 대칭키(symmetric key) 방식이지만, 디지털 서명은 개인 키(private key)로 서명하고 공개 키(public key)로 검증합니다. 공개 키만 가진 검증자는 유효한 서명을 새로 만들 수 없죠.

도구검증에 필요한 신뢰검증자가 인증값을 만들 수 있나대표적인 쓰임새
일반 해시없음기대한 해시를 믿을 수 있어야 함가능파일 체크섬, SRI
HMAC공유 비밀 키키를 안전하게 공유해야 함가능웹훅, API 요청, HS256
디지털 서명개인 키와 공개 키공개 키의 소유자를 확인해야 함불가능소프트웨어 서명, 인증서, RS256

검증자가 한 서비스 안에 있고 비밀 키를 안전하게 공유할 수 있다면 HMAC은 빠르고 단순한 선택입니다. 반대로 여러 외부 서비스가 검증해야 하거나 검증자에게 생성 권한을 주면 안 된다면 디지털 서명이 더 잘 맞습니다.

비밀 키를 그냥 붙여 해시하면 안 될까요?

가장 먼저 떠올릴 수 있는 방법은 비밀 키와 메시지를 이어 붙여 한 번 해시하는 것입니다.

직접 만든 조합
hash(비밀 키 || 메시지)

하지만 SHA-256처럼 블록을 차례로 처리하는 일부 해시 함수에서는 이런 단순한 앞붙이기 방식이 길이 연장 공격(length-extension attack)에 노출될 수 있습니다. 공격자가 원래 메시지와 해시값을 알면 비밀 키의 길이를 추측해 키를 모르고도 뒤에 데이터를 덧붙인 메시지의 유효한 해시값을 계산할 수 있는 문제입니다.

HMAC은 이런 임의 조합 대신 검토된 안쪽 해시와 바깥쪽 해시 구조를 사용합니다.

HMAC의 핵심 구조
HMAC(key, message) =
  H((key ⊕ opad) || H((key ⊕ ipad) || message))

ipadopad는 안쪽과 바깥쪽 계산을 구분하는 고정된 패딩 값입니다. 먼저 키와 ipad, 메시지를 함께 해시하고, 그 결과를 키와 opad에 다시 넣어 한 번 더 해시하죠. 키가 해시 함수의 블록보다 길면 먼저 해시해 줄이고, 짧으면 블록 길이에 맞게 채웁니다.

공식을 직접 구현할 필요는 없습니다. RFC 2104의 표준을 따르는 구현이나 플랫폼의 암호 API를 사용하면 됩니다. 이 구조를 알아두면 “키를 한쪽에 붙여 한 번 해시해도 비슷하겠지”라는 위험한 지름길을 피할 수 있습니다.

새로 고른다면 HMAC-SHA-256이 무난합니다

HMAC은 내부에서 사용할 해시 함수의 이름을 뒤에 붙여 구분합니다. SHA-256을 사용하면 HMAC-SHA-256, SHA-512를 사용하면 HMAC-SHA-512라고 부르는 식입니다.

새 시스템에서는 호환성이 넓고 출력 길이도 충분한 HMAC-SHA-256이 무난한 출발점입니다. RFC 4231은 HMAC-SHA-256을 비롯한 SHA-2 계열의 테스트 벡터를 제공하므로, 구현이 표준과 같은 결과를 내는지 확인할 때도 사용할 수 있습니다.

HMAC-MD5나 HMAC-SHA-1을 사용하는 기존 시스템을 만날 수는 있습니다. 일반 해시의 충돌 공격이 HMAC을 똑같은 방식으로 즉시 깨뜨리는 것은 아니지만, 새 설계에서 오래된 해시를 고를 이유는 없습니다. RFC 6151도 새로운 프로토콜에 HMAC-MD5를 넣지 말고 HMAC-SHA-256 같은 대안을 사용하라고 권고합니다.

알고리즘만큼 비밀 키의 품질도 중요합니다. HMAC-SHA-256에는 암호학적으로 안전한 난수 생성기로 만든 256비트 비밀 키를 사용하면 좋습니다. 사람이 외울 만한 문장이나 짧은 API 이름을 키로 사용하면 공격자가 후보를 대입해 맞힐 수 있어요.

비밀 키는 소스 코드나 저장소에 넣지 말고 비밀 관리 서비스나 안전한 환경 변수에서 읽어야 합니다. 서비스와 용도마다 키를 분리하고, 유출에 대비해 키 식별자와 교체 절차도 준비해 두는 편이 좋습니다.

웹훅의 원본 바이트를 검증해 볼까요?

이제 Bun 서버에서 HMAC-SHA-256 웹훅을 검증해 보겠습니다. 예제에서는 발신자가 타임스탬프(timestamp)와 요청 본문을 .으로 이어 인증 태그를 만들고, 16진수 문자열로 전송한다고 가정하겠습니다.

발신자와 수신자가 약속한 입력
HMAC-SHA-256(비밀 키, 타임스탬프 + "." + 요청 본문의 원본 바이트)
server.ts
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const secret = Bun.env.WEBHOOK_SECRET;

if (!secret) {
  throw new Error("WEBHOOK_SECRET 환경 변수가 필요합니다.");
}

const hmacKey = await crypto.subtle.importKey(
  "raw",
  encoder.encode(secret),
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["verify"],
);

function decodeHex(value: string): Uint8Array | null {
  if (!/^[0-9a-f]{64}$/i.test(value)) {
    return null;
  }

  const pairs = value.match(/../g);
  return Uint8Array.from(pairs ?? [], (pair) => Number.parseInt(pair, 16));
}

function makeSignedData(timestamp: string, body: Uint8Array): Uint8Array {
  const prefix = encoder.encode(`${timestamp}.`);
  const data = new Uint8Array(prefix.length + body.length);

  data.set(prefix);
  data.set(body, prefix.length);
  return data;
}

Bun.serve({
  port: 3000,
  async fetch(request) {
    const url = new URL(request.url);

    if (request.method !== "POST" || url.pathname !== "/webhooks/payment") {
      return new Response("Not Found", { status: 404 });
    }

    const timestamp = request.headers.get("x-webhook-timestamp");
    const signatureHeader = request.headers.get("x-webhook-signature");

    if (!timestamp || !signatureHeader) {
      return new Response("Missing signature", { status: 401 });
    }

    const issuedAt = Number(timestamp);
    const now = Math.floor(Date.now() / 1000);

    if (!Number.isSafeInteger(issuedAt) || Math.abs(now - issuedAt) > 300) {
      return new Response("Expired request", { status: 401 });
    }

    const signature = decodeHex(signatureHeader);

    if (!signature) {
      return new Response("Invalid signature format", { status: 401 });
    }

    const body = new Uint8Array(await request.arrayBuffer());
    const signedData = makeSignedData(timestamp, body);
    const isValid = await crypto.subtle.verify(
      "HMAC",
      hmacKey,
      signature,
      signedData,
    );

    if (!isValid) {
      return new Response("Invalid signature", { status: 401 });
    }

    const event = JSON.parse(decoder.decode(body));
    console.log(event);

    return new Response("OK");
  },
});

crypto.subtle.importKey()는 비밀 키 바이트를 HMAC용 CryptoKey로 가져오고, crypto.subtle.verify()는 인증 태그가 메시지와 맞는지를 불리언 값으로 알려 줍니다. 직접 계산한 두 문자열을 ===로 비교하는 대신 검증 API에 인증 태그와 원본 데이터를 넘겼습니다.

본문을 먼저 request.json()으로 읽지 않은 데에는 이유가 있습니다. JSON을 객체로 바꾼 뒤 다시 직렬화하면 공백, 속성 순서, 문자 인코딩이 달라질 수 있습니다. HMAC은 의미가 같은 JSON이 아니라 바이트가 같은 메시지에서만 같은 결과를 내므로, 발신자가 서명한 원본 바이트를 먼저 검증해야 합니다. 검증에 성공한 다음에야 본문을 파싱하고 업무 로직을 실행합니다.

유효한 태그도 재전송될 수 있습니다

HMAC 검증에 성공했다고 요청이 한 번만 전송됐다고 가정해서는 안 됩니다. 공격자가 비밀 키를 몰라도 정상 요청과 인증 태그를 어떤 경로로든 얻으면 그대로 다시 보낼 수 있기 때문입니다. 메시지를 고치는 위조와 유효한 메시지를 복사하는 재전송 공격(replay attack)은 다른 문제입니다.

앞의 예제는 타임스탬프를 인증 대상에 넣고 현재 시각과 5분 이상 차이 나는 요청을 거부합니다. 공격자가 헤더의 타임스탬프만 최신 값으로 바꾸면 인증 태그가 맞지 않으므로 함께 위조할 수도 없습니다.

하지만 5분 안에는 같은 요청이 다시 올 수 있습니다. 결제나 계정 변경처럼 중복 처리가 위험한 작업이라면 이벤트 ID나 요청 ID도 인증 대상에 포함하고, 이미 처리한 ID를 저장해 두 번째 요청을 거부해야 합니다. 웹훅 제공자가 재시도할 수 있다는 점을 고려해 처리 로직 자체도 멱등성(idempotency)을 갖추는 편이 안전합니다.

실무에서 자주 놓치는 부분

HMAC 자체는 단순하지만, 무엇을 어떤 키로 인증했는지가 어긋나면 검증이 쉽게 깨지거나 우회됩니다.

  • 발신자 문서가 정한 헤더, 인코딩, 필드 순서를 그대로 따릅니다. Base64와 16진수는 서로 바꿔 쓸 수 없습니다.
  • 파싱하거나 정규화하기 전의 원본 바이트를 검증합니다. 프록시나 미들웨어가 본문을 바꾸지 않는지도 확인합니다.
  • 타임스탬프를 메시지와 함께 인증하고 허용 시간 범위를 제한합니다. 중요한 작업에는 일회성 요청 ID도 사용합니다.
  • 하나의 비밀 키를 여러 서비스와 용도에 재사용하지 않습니다. 한 곳의 유출이 다른 연동으로 번지는 일을 막아 줍니다.
  • 요청이 지정한 알고리즘을 그대로 믿지 않습니다. 서버가 허용한 HMAC-SHA-256처럼 고정된 알고리즘만 사용합니다.
  • 태그를 지나치게 짧게 자르지 않습니다. 프로토콜이 절단 길이를 정했다면 그 규칙을 정확히 따르고, 임의로 설계하지 않습니다.

비밀 키가 유출되면 공격자는 정상 태그를 만들 수 있습니다. 이때는 해시 알고리즘을 바꾸는 것이 아니라 키를 폐기하고 새 키로 교체해야 합니다. 교체 중에는 키 식별자로 이전 키와 새 키를 구분하고, 짧은 전환 기간이 끝나면 이전 키를 완전히 거부하는 방식이 유용합니다.

HMAC은 어디에서 만날까요?

HMAC은 웹훅과 API 요청 인증에서 자주 만납니다. 요청 본문, 경로, 타임스탬프처럼 양쪽이 약속한 데이터를 HMAC으로 묶으면 중간에 값이 바뀌었는지와 공유 키를 아는 발신자가 보냈는지를 함께 확인할 수 있습니다.

JWS와 JWT의 HS256도 HMAC-SHA-256을 사용합니다. JWS 표준에서는 결과를 서명(signature)이라고 부르지만 암호학적으로는 공유 비밀 키를 쓰는 메시지 인증 코드인데요. 토큰을 검증할 수 있는 서비스라면 같은 키로 새 토큰도 만들 수 있다는 뜻이므로, 검증 서비스를 여러 곳으로 늘려야 할 때는 RS256, ES256, EdDSA 같은 공개 키 서명이 더 잘 맞습니다.

HMAC은 키 유도 함수의 내부 부품으로도 쓰입니다. 예를 들어 TLS 1.3에서 세션 키를 만드는 HKDF(HMAC-based Key Derivation Function)는 이름 그대로 HMAC을 바탕으로 입력 키 재료에서 여러 용도의 키를 안전하게 끌어냅니다. 이 경우 HMAC은 웹훅처럼 메시지를 직접 인증하기보다 새 키를 만드는 의사 난수 함수(pseudorandom function) 역할을 합니다.

마치며

일반 해시는 메시지가 같은지를 비교할 수 있지만 누구나 새 해시값을 계산할 수 있습니다. HMAC은 여기에 공유 비밀 키를 더해, 메시지가 바뀌지 않았고 그 키를 아는 쪽이 인증 태그를 만들었다는 사실까지 확인합니다.

대신 메시지를 숨겨 주거나 특정 참여자의 행위를 제삼자에게 증명하지는 않습니다. 검증자도 태그를 만들 수 있다는 대칭키의 성질을 이해하면 HMAC과 디지털 서명 중 무엇을 골라야 하는지 선명해집니다.

실무에서는 HMAC-SHA-256 같은 검증된 조합을 사용하고, 원본 바이트와 타임스탬프를 함께 인증하세요. 안전한 난수 키, 용도별 키 분리, 재전송 방지까지 갖춰야 HMAC의 수학적인 보장이 실제 시스템의 보안으로 이어집니다.

JavaScript에서 HMAC을 계산하고 검증하는 API 자체가 궁금하다면 Web Crypto API 글도 함께 읽어 보세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord