본문으로 건너뛰기
yutils

Web Crypto API — 브라우저 안에서 SHA·HMAC·AES·JWT 를 라이브러리 없이

브라우저 내장 crypto.subtle 으로 SHA/HMAC/AES/JWT 구현. digest·sign·encrypt·키 import/export, 보안용 getRandomValues 가 Math.random 을 대체하는 이유.

약 9분 읽기

브라우저에서 SHA-256 해시 한 번 만들고 싶을 때 — crypto-js, jsSHA, bcryptjs 같은 외부 라이브러리를 까는 게 관행이었다. 사실 모던 브라우저 (그리고 Node 15+, Deno, Bun, Cloudflare Workers) 는 crypto.subtle 이라는 표준 Web Crypto API 를 내장하고 있다. 외부 의존 0, 네이티브 속도, secure context (HTTPS) 필수. 이 가이드는 가장 흔히 쓰는 5 가지 — digest / HMAC / 랜덤 / AES / JWT — 패턴을 정리한다.

왜 Web Crypto 인가

  • 번들 0 KB — crypto-js 만 해도 gzip 50 KB+. Web Crypto 은 브라우저 안에 이미 있음.
  • 네이티브 속도 — C++ 구현. JS 라이브러리 대비 10~100× 빠른 케이스 흔함.
  • 표준 — W3C Web Cryptography API (2017). 같은 API 가 브라우저·Node·Deno·Workers 모두에서 동작.
  • 안전 — key 객체가 raw bytes 노출 X (CryptoKey). 타이밍 공격 방어 내장.

전제 — Secure Context

crypto.subtleHTTPS 또는 localhost 에서만 동작. http://example.com 에서는 undefined. IP 주소로 직접 접근하는 경우도 X.

if (!window.isSecureContext) {
  console.warn("crypto.subtle requires HTTPS");
}
if (!crypto?.subtle) {
  throw new Error("Web Crypto not available");
}

1. Digest — SHA-256/384/512

가장 흔한 사용처. 텍스트 또는 바이트의 단방향 해시.

async function sha256(text) {
  const data = new TextEncoder().encode(text);
  const hash = await crypto.subtle.digest("SHA-256", data);
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

await sha256("hello");
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"

TextEncoder 가 UTF-8 바이트로 변환. digest ArrayBuffer 반환 → Uint8Array → hex 문자열. 같은 결과를 SHA 해시 에 입력해 비교 가능. SHA-1 / SHA-384 / SHA-512 알고리즘 문자열만 바꾸면 됨. SHA-1 은 충돌 발견되어 보안 용도 비추 (체크섬 한정).

2. HMAC — Sign & Verify

시크릿 키로 메시지 서명. webhook 검증·JWT 서명·세션 토큰 등에 사용.

async function hmacSign(message, secret) {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    enc.encode(secret),
    {name: "HMAC", hash: "SHA-256"},
    false,
    ["sign"]
  );
  const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
  return Array.from(new Uint8Array(sig))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

verify 는 키와 메시지 + 받은 서명 (bytes) 으로 boolean 반환. constant-time 비교 내장 — JS 의 === 보다 안전. 같은 입력을 HMAC 생성기 에 넣어 결과를 비교할 수 있다.

3. 보안 랜덤 — getRandomValues

Math.random() 은 시드가 예측 가능 — 보안 토큰·CSRF 시크릿· UUID 에 절대 사용 X. 대신 crypto.getRandomValues:

// 16 바이트 보안 랜덤
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);

// 표준 UUID v4
const id = crypto.randomUUID();
// "550e8400-e29b-41d4-a716-446655440000"

// 32 자 hex 토큰
const token = Array.from(crypto.getRandomValues(new Uint8Array(16)))
  .map((b) => b.toString(16).padStart(2, "0"))
  .join("");

crypto.randomUUID() 는 RFC 4122 v4 호환. 동일 결과를 UUID / ULID 생성기 에서 일괄 생성 가능 (v7/ULID 옵션 포함).

4. AES — 대칭 암복호

브라우저 안 데이터를 안전히 저장하거나 (E2E 메시징, password vault) 짧은 문자열을 시크릿으로 보호할 때.

async function encrypt(plaintext, password) {
  const enc = new TextEncoder();

  // 1. password → key (PBKDF2)
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const keyMaterial = await crypto.subtle.importKey(
    "raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]
  );
  const key = await crypto.subtle.deriveKey(
    {name: "PBKDF2", salt, iterations: 600000, hash: "SHA-256"},
    keyMaterial,
    {name: "AES-GCM", length: 256},
    false,
    ["encrypt"]
  );

  // 2. AES-GCM encrypt with random IV
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ct = await crypto.subtle.encrypt(
    {name: "AES-GCM", iv}, key, enc.encode(plaintext)
  );

  // salt + iv + ciphertext 한 묶음 (decrypt 시 같은 salt/iv 필요)
  return {
    salt: Array.from(salt),
    iv: Array.from(iv),
    ct: Array.from(new Uint8Array(ct))
  };
}

포인트:

  • AES-GCM 권장 — 인증 암호 (AEAD). CBC 보다 안전.
  • IV (nonce) 는 매번 새로 12 바이트. 같은 키 + 같은 IV 재사용 = GCM 안전성 전체 붕괴.
  • PBKDF2 600 000 iterations (2026 OWASP 권장). 사용자 password 를 key 로 변환할 때.

5. JWT 직접 서명

Web Crypto 만으로 HS256 JWT 발급. 라이브러리 없이도 토큰 만들 수 있다.

async function signJwt(payload, secret) {
  const enc = new TextEncoder();
  const header = {alg: "HS256", typ: "JWT"};

  const b64url = (obj) =>
    btoa(JSON.stringify(obj))
      .replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");

  const signingInput = b64url(header) + "." + b64url(payload);

  const key = await crypto.subtle.importKey(
    "raw", enc.encode(secret),
    {name: "HMAC", hash: "SHA-256"},
    false, ["sign"]
  );
  const sig = await crypto.subtle.sign("HMAC", key, enc.encode(signingInput));

  const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
    .replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");

  return signingInput + "." + sigB64;
}

간단한 알고리즘 (HS256/384/512) 은 위 패턴 그대로. RS256/ES256 같은 비대칭은 generateKeyPair + exportKey 로 PEM 키 교환 가능. 결과를 JWT 생성기 (HMAC) 또는 JWT 디코더 로 검증하면 동일.

키 다루기 — CryptoKey 객체

Web Crypto 의 키는 raw bytes 아니라 CryptoKey 객체. JS 메모리에서도 추출 불가 (extractable: false 옵션). 이걸로 키 누출 공격 면역.

  • importKey — raw/JWK/PKCS8/SPKI → CryptoKey
  • exportKey — CryptoKey → raw/JWK/PKCS8/SPKI (extractable 일 때만)
  • generateKey — 새 키 생성
  • deriveKey — 다른 키/패스워드로부터 도출 (PBKDF2 등)

장기 키는 IndexedDB 에 CryptoKey 그대로 저장 가능 (브라 우저가 객체 그대로 직렬화). raw bytes 가 JS 에 노출되지 않음.

흔한 함정

1. 동기 함수로 착각

모든 crypto.subtle.* 가 Promise. await 잊으면 [object Promise] 의 해시가 나오는 등 silently 잘못 동작.

2. ArrayBuffer 직접 비교

ArrayBuffer=== 로 비교하면 항상 false. Uint8Array 로 wrap 후 바이트 비교. 인증된 데이터는 crypto.subtle.verify 가 constant-time 비교 처리.

3. base64 표준 vs base64url

btoa 는 표준 base64 — +, /, 패딩 = 출력. JWT 는 base64url 이라 변환 필요.

4. IV 재사용

AES-GCM 에서 같은 키 + 같은 IV 재사용 = 평문 노출. 매 암호화마다 새 IV 필수. 12 바이트 길이가 GCM 표준.

5. Math.random 으로 토큰 생성

예측 가능한 시드. 보안 컨텍스트에서는 무조건 getRandomValues 또는 randomUUID.

6. 브라우저 호환성 가정

모던 브라우저 (Chrome 37+, FF 34+, Safari 11+, Edge 12+) 다 지원하지만 구식 환경·webview 는 제한. crypto.subtle 존재 확인 필수.

Node·Deno·Workers 호환

같은 API 가:

  • Node 15+: globalThis.crypto.subtle 또는 import {webcrypto} from "crypto"
  • Deno: 글로벌 crypto.subtle.
  • Cloudflare Workers: 글로벌 crypto.subtle.
  • Bun: 글로벌 crypto.subtle.

Edge 함수·SSR·서버리스 모두에서 동일 코드 동작. Cloudflare Workers 같은 에지 환경은 native crypto 만 허용하므로 사실상 강제.

요약

  • crypto.subtle 은 HTTPS 에서 동작하는 표준 Web Crypto API. 외부 라이브러리 필요 없음.
  • 핵심 동작: digest / sign+verify / encrypt+decrypt / deriveKey / generateKey. 모두 async.
  • AES-GCM + 매번 새 IV. PBKDF2 600 000 iterations. AES-CBC 는 비추.
  • 보안 랜덤은 crypto.getRandomValues / crypto.randomUUID. Math.random X.
  • CryptoKey 는 raw bytes 노출 X — 안전한 장기 저장 (IndexedDB) 가능.
  • Node·Deno·Workers·Bun 모두 같은 API. SSR·Edge 함수에서도 동일 코드.
가이드 목록으로