브라우저에서 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.subtle 은 HTTPS 또는 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.randomX. - CryptoKey 는 raw bytes 노출 X — 안전한 장기 저장 (IndexedDB) 가능.
- Node·Deno·Workers·Bun 모두 같은 API. SSR·Edge 함수에서도 동일 코드.