Web Crypto API로 브라우저에서 암호화 다루기
자바스크립트로 무언가를 만들다 보면 의외로 자주 마주치는 순간이 있습니다. 세션 토큰을 만들거나, 비밀번호를 해시하거나, 파일 무결성을 검증하거나, 쿠키에 서명을 넣어야 할 때인데요. 예전 같으면 별도의 라이브러리를 깔아야 했지만, 요즘은 브라우저와 서버 런타임 모두 이런 일을 표준 API로 해낼 수 있습니다. 바로 Web Crypto API입니다.
이 글에서 Web Crypto API의 사용법을 먼저 다루지만, 그 뒤에 깔린 대칭키와 비대칭키 암호화의 원리가 궁금하다면 별도 글에서 따로 풀어 두었습니다.
이름만 들으면 브라우저 전용 같지만 사실 그렇지 않습니다. Node.js 15 이상, Bun, Deno, Cloudflare Workers까지 같은 인터페이스를 그대로 노출하고 있어서, 한 번 익혀두면 어디서든 똑같은 코드를 쓸 수 있는데요. 이번 글에서는 Web Crypto API가 어떤 일을 해주는지, 그리고 실무에서 자주 쓰는 패턴들을 차근차근 살펴보겠습니다.
Math.random()으로는 안 되는 이유
본격적으로 들어가기 전에 한 가지 짚고 갈 게 있습니다.
“난수가 필요하면 Math.random() 쓰면 되지 않나요?”라는 질문인데요.
일반적인 용도(주사위, 게임 로직, 색상 셔플)라면 충분하지만, 보안이 관련된 순간 절대로 써서는 안 됩니다.
Math.random()은 의사 난수 생성기(PRNG)라서 내부 상태를 알면 다음에 나올 값을 예측할 수 있는데요.
실제로 V8 엔진의 Math.random()은 xorshift128+ 알고리즘을 쓰는데, 충분한 출력 샘플을 모으면 시드를 역산할 수 있다고 알려져 있습니다.
세션 토큰이나 비밀번호 리셋 토큰을 이 함수로 만들면 공격자가 다른 사용자의 토큰을 추측해낼 수 있다는 뜻입니다.
Web Crypto API의 crypto.getRandomValues()는 운영체제의 암호학적으로 안전한 난수 생성기(CSPRNG)를 사용합니다.
브라우저라면 OS의 /dev/urandom이나 BCryptGenRandom을 호출하고, Node.js라면 OpenSSL을 거치는 식이죠.
예측이 불가능하다 보니 토큰이나 키처럼 보안에 영향을 주는 값은 무조건 이쪽으로 만들어야 합니다.
안전한 난수 만들기
crypto.getRandomValues()는 typed array를 인자로 받아 그 자리에 난수를 채워넣는 방식으로 동작합니다.
반환값도 같은 배열이라 체이닝이 가능한데요.
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
console.log(bytes);
// Uint8Array(16) [ 234, 12, 198, 77, ..., 51 ]
Uint8Array 외에도 Uint16Array, Uint32Array, Int8Array 같은 정수 타입 배열을 모두 받습니다.
한 번에 채울 수 있는 최대 크기는 65,536바이트로 제한되는데, 너무 큰 난수를 한 번에 요청하지 못하게 막아둔 것입니다.
실무에서 자주 쓰는 패턴은 난수 바이트를 16진수나 Base64 문자열로 바꿔서 토큰으로 쓰는 것인데요. 세션 ID나 일회성 토큰을 만들 때 유용합니다.
function generateToken(byteLength = 32) {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
console.log(generateToken());
// "a3f1c8...총 64자의 16진수 문자열"
32바이트면 256비트 엔트로피라 사실상 충돌 걱정 없는 토큰이 됩니다.
PKCE의 code_verifier나 CSRF 토큰처럼 “공격자가 절대 추측할 수 없어야 하는 값”을 만들 때 그대로 쓸 수 있습니다.
randomUUID()로 한 줄 UUID
UUID v4를 만들 일이 있다면 더 짧은 방법이 있습니다.
crypto.randomUUID()를 호출하기만 하면 표준 형식의 UUID가 바로 나오는데요.
console.log(crypto.randomUUID());
// "cbfc904b-b898-4deb-b736-ba433489904c"
내부적으로 getRandomValues()로 16바이트를 만든 뒤 버전 비트와 변형 비트를 박아넣어 36자 문자열로 포맷팅하는 일을 한 줄로 줄여준 것입니다.
별도 패키지를 깔지 않아도 되고, 충돌 확률은 사실상 0이라고 봐도 무방합니다.
여기에 대해서는 자바스크립트로 UUID 생성하기에서 더 자세히 다뤘으니 그쪽도 함께 보시면 좋습니다.
참고로 더 짧은 식별자가 필요하다면 NanoID도 좋은 선택지인데, 이쪽도 내부적으로 같은 getRandomValues()를 사용합니다.
SubtleCrypto가 비동기인 이유
여기서부터는 본격적인 암호 연산을 다루는 crypto.subtle 영역입니다.
이름이 “subtle(미묘한)“인 데에는 이유가 있는데요.
W3C 명세에서 “이 API는 잘못 쓰면 보안에 미묘하지만 치명적인 영향을 줄 수 있다”는 경고를 이름에 담은 것입니다.
또 하나 특이한 점은 subtle의 모든 메서드가 Promise를 반환한다는 것입니다.
const data = new TextEncoder().encode("hello");
const hash = await crypto.subtle.digest("SHA-256", data);
해시 같은 짧은 연산까지 비동기로 만든 이유는 두 가지인데요.
하나는 큰 데이터에 대한 암호화/복호화가 메인 스레드를 오래 점유하지 않도록 하기 위해서이고, 다른 하나는 하드웨어 가속(예: AES-NI 명령어, 보안 칩)을 워커 스레드에서 활용할 여지를 두기 위해서입니다.
덕분에 호출부는 무조건 async/await나 .then()을 거쳐야 합니다.
해시 만들기 (SHA-256)
가장 자주 쓰이는 연산이 해시입니다.
crypto.subtle.digest()에 알고리즘 이름과 바이트 배열을 넘기면 해시 결과를 ArrayBuffer로 돌려주는데요.
지원하는 알고리즘은 SHA-1, SHA-256, SHA-384, SHA-512 네 가지입니다.
async function sha256(text) {
const data = new TextEncoder().encode(text);
const buffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
console.log(await sha256("hello"));
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
문자열을 바이트로 바꾸기 위해 TextEncoder를 거쳐야 한다는 점이 살짝 번거롭지만, 한 번 패턴을 익히면 어렵지 않습니다.
파일 무결성 검증, PKCE의 code_challenge 생성, 캐시 키 만들기 같은 곳에 두루 쓸 수 있습니다.
여기서 주의할 점이 있는데요.
SHA-1은 충돌 공격이 실제로 발견되어 보안 목적으로는 더 이상 권장되지 않습니다.
호환성 때문에 명세에 남아 있을 뿐, 새로 만드는 코드에서는 SHA-256 이상을 쓰는 것이 안전합니다.
또한 비밀번호 해시에는 SHA-256을 그대로 쓰면 안 됩니다.
무지개 테이블이나 GPU 무차별 대입에 취약하기 때문에 bcrypt, scrypt, argon2 같은 전용 알고리즘을 써야 하는데, 안타깝게도 Web Crypto API는 이들을 직접 지원하지 않습니다.
대신 PBKDF2라는 키 유도 함수는 지원하므로, 클라이언트 측에서 비밀번호로부터 키를 파생할 때 활용할 수 있습니다.
키 다루기
암호화나 서명을 하려면 먼저 키가 있어야 합니다.
Web Crypto API는 키를 단순 바이트가 아닌 CryptoKey라는 불투명한 객체로 다루는데요.
키를 메모리에서 직접 들여다볼 수 없게 막아두는 일종의 보안 장치입니다.
새 키는 generateKey()로 만들고, 외부에서 받은 키 바이트는 importKey()로 변환합니다.
나중에 다시 바이트로 빼내야 한다면 exportKey()를 쓰는데, 이때 extractable: true로 만들어둔 키만 추출할 수 있습니다.
const key = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true, // extractable
["encrypt", "decrypt"], // 허용된 사용처
);
세 번째 인자인 사용처(keyUsages) 배열에 주의해야 합니다.
여기에 "encrypt"만 적어두면 그 키로 복호화를 시도할 때 에러가 나는데요.
“이 키는 암호화 전용”이라는 의도를 코드 수준에서 강제할 수 있어, 실수로 키를 잘못 쓰는 일을 막아줍니다.
대칭키 암호화 (AES-GCM)
같은 키로 암호화와 복호화를 모두 하는 방식이 대칭키 암호화입니다.
Web Crypto API에서는 AES-GCM을 가장 많이 쓰는데요.
GCM 모드는 암호화와 동시에 무결성 검증까지 해주기 때문에 별도 MAC을 붙일 필요가 없어 편리합니다.
async function encrypt(plaintext, key) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = new TextEncoder().encode(plaintext);
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
data,
);
return { iv, ciphertext };
}
async function decrypt({ iv, ciphertext }, key) {
const data = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
ciphertext,
);
return new TextDecoder().decode(data);
}
iv는 초기화 벡터(initialization vector)로, 같은 키로 암호화할 때마다 반드시 새로 만들어야 합니다.
재사용하면 GCM의 보안성이 완전히 무너지는데요.
다행히 12바이트 난수를 매번 새로 뽑으면 충돌 확률이 무시할 수 있을 만큼 작습니다.
iv는 비밀이 아니므로 암호문과 함께 평문으로 저장하거나 전송해도 됩니다.
복호화할 때 같은 iv만 다시 넣어주면 되기 때문이죠.
const key = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"],
);
const encrypted = await encrypt("비밀 메시지", key);
const decrypted = await decrypt(encrypted, key);
console.log(decrypted);
// "비밀 메시지"
암호문을 임의로 한 바이트만 바꿔도 복호화가 실패하는데요. GCM 모드가 무결성 태그를 함께 검증하기 때문입니다. 누군가 중간에서 메시지를 변조하면 바로 들킨다는 뜻이라 굉장히 안심할 수 있습니다.
서명과 검증 (HMAC)
메시지가 변조되지 않았다는 것을 증명하려면 서명을 사용합니다. HMAC은 대칭키 기반의 서명 방식인데, 같은 비밀키를 가진 사람만 서명을 만들고 검증할 수 있습니다.
const key = await crypto.subtle.generateKey(
{ name: "HMAC", hash: "SHA-256" },
true,
["sign", "verify"],
);
const message = new TextEncoder().encode("주문번호: 1234");
const signature = await crypto.subtle.sign("HMAC", key, message);
const isValid = await crypto.subtle.verify("HMAC", key, signature, message);
console.log(isValid); // true
쿠키에 서명을 붙이거나 JWT의 HS256 알고리즘을 직접 구현할 때 이런 패턴을 그대로 사용합니다.
JWT의 서명 부분은 결국 헤더와 페이로드를 연결한 문자열에 HMAC-SHA256을 적용한 것이라, 위 코드만 알면 라이브러리 없이도 직접 만들 수 있는 셈입니다.
비대칭키 서명이 필요하다면 RSASSA-PKCS1-v1_5, RSA-PSS, ECDSA 같은 알고리즘도 같은 패턴으로 사용할 수 있습니다.
공개키와 개인키가 분리되어 있어 서명자와 검증자가 다를 때(예: OAuth 인가 서버가 토큰에 서명하고 리소스 서버가 검증하는 JWKS 시나리오) 적합합니다.
어디에서 쓸 수 있나
처음에 잠깐 언급했지만 다시 정리해보면, Web Crypto API는 거의 모든 자바스크립트 환경에서 쓸 수 있습니다.
브라우저는 IE를 제외한 모든 모던 브라우저가 지원하고, Node.js는 15.0부터 글로벌 crypto 객체를 노출합니다.
Bun과 Deno는 처음부터 표준 그대로 구현했고, Cloudflare Workers나 Vercel Edge Runtime 같은 엣지 환경도 마찬가지인데요.
런타임 분기 없이 같은 코드가 어디서나 동작한다는 점이 이 API의 가장 큰 장점입니다.
다만 Node.js에는 별도로 node:crypto라는 더 풍부한 모듈이 있는데, 이쪽은 동기 API와 스트림 인터페이스, bcrypt/scrypt 등 추가 알고리즘을 제공합니다.
Node.js에서만 돌아가는 코드라면 둘 중 편한 쪽을 골라 쓰면 되고, 브라우저나 엣지에서도 돌아야 한다면 Web Crypto API 쪽을 선택하는 것이 안전합니다.
자주 만나는 함정
마지막으로 실수하기 쉬운 부분 몇 가지를 짚어보겠습니다.
crypto.subtle은 보안 컨텍스트(secure context)에서만 사용할 수 있습니다.
HTTPS이거나 localhost여야 한다는 뜻인데요.
HTTP로 띄운 사이트에서는 crypto.subtle이 undefined라 호출 자체가 실패합니다.
로컬 개발은 localhost로 하면 문제없지만, 사내 IP로 접속하는 환경이라면 자체 서명 인증서를 띄워야 합니다.
또한 ArrayBuffer와 Uint8Array를 헷갈리기 쉽습니다.
대부분의 메서드는 둘 다 받지만, 결과값은 항상 ArrayBuffer로 돌려주기 때문에 바이트로 다루려면 new Uint8Array(buffer)로 한 번 감싸줘야 합니다.
마지막으로 알고리즘 파라미터의 형식이 알고리즘마다 조금씩 다릅니다.
AES-GCM은 { name, iv, additionalData }를 받지만 RSA-OAEP는 { name, label }을 받는 식인데요.
처음에는 명세나 MDN 문서를 옆에 두고 작성하는 것이 안전합니다.
마치며
Web Crypto API는 한 번 익혀두면 두루두루 쓸모가 많은 도구입니다. 난수, 해시, 암호화, 서명까지 보안에 필요한 거의 모든 연산을 표준 인터페이스 하나로 처리할 수 있고, 브라우저부터 서버 런타임까지 어디서나 같은 코드가 동작하니까요.
다만 암호화는 “동작하면 끝”이 아니라 “올바르게 써야 끝”인 영역입니다.
키 관리, IV 재사용 방지, 알고리즘 선택 같은 부분에서 실수하면 안전하지 않은 코드가 멀쩡히 돌아가는 일이 흔한데요.
가능하면 검증된 라이브러리(jose, oauth4webapi 등)를 쓰고, 직접 구현해야 한다면 명세를 꼼꼼히 읽어보는 것을 권합니다.
더 자세한 API 레퍼런스는 MDN의 Web Crypto API 문서에서 확인할 수 있습니다.
This work is licensed under
CC BY 4.0