코드 리뷰를 하다 보면 가끔 이런 주석을 봐요. "비밀번호는 Base64로 암호화해서 저장". 의도는 이해하지만, 이건 현관문에 열쇠를 꽂아 둔 채로 "잠갔음"이라고 써 붙인 것과 비슷해요. 결론부터 말하면 Base64는 암호화가 아니라 인코딩이에요. 키가 없어서 누구나 명령 한 줄로 원문을 복원할 수 있고, 비밀을 지키는 기능은 전혀 없어요.
그럼 비밀번호는 뭘로 저장하고, API 키나 개인정보는 어떻게 숨겨야 할까요? 인코딩·암호화·해시가 각각 무엇을 위한 도구인지, 같은 입력을 Node.js 22에서 세 방식으로 직접 처리해 보면서 정리했어요. 끝까지 보시면 "Base64로 감춰 놨으니 괜찮다"는 코드를 어디서 고쳐야 할지 감이 오실 거예요.
핵심 요약
Base64는 바이너리를 텍스트로 안전하게 운반하기 위한 인코딩이라, 키 없이 누구나 즉시 되돌릴 수 있어요.
내용을 숨기려면 키가 필요한 암호화(AES-GCM 등)를, 비밀번호 저장에는 되돌릴 수 없고 일부러 느린 해시(Argon2id, scrypt, bcrypt)를 써야 해요.
암호문이나 해시를 Base64로 표현하는 건 정상이에요. 보안은 암호화·해시가 담당하고 Base64는 포장만 해요.
JWT, 쿠버네티스 Secret, HTTP Basic 인증의 Base64 부분은 모두 누구나 읽을 수 있다고 가정해야 해요.
Base64는 1초 만에 풀린다 — 직접 확인
password123 을 Base64로 바꾼 값이 cGFzc3dvcmQxMjM= 처럼 생겼다면, 사람 눈엔 암호처럼 보여요. 하지만 되돌리는 데 필요한 건 명령 한 줄이에요.
echo cGFzc3dvcmQxMjM= | base64 -d
# password123
Buffer.from('cGFzc3dvcmQxMjM=', 'base64').toString(); // 'password123'
atob('cGFzc3dvcmQxMjM='); // 브라우저: 'password123'
키도, 비밀번호도, 추측도 필요 없어요. Base64는 64개 문자 표가 공개된 표준(RFC 4648)이라 모든 언어의 표준 라이브러리, 모든 운영체제의 터미널, 수많은 웹 도구가 즉시 풀어 줘요. Base64 인코더/디코더에 붙여 넣어도 바로 원문이 나오고요.
특히 = 로 끝나거나 ey 로 시작하는 문자열은 개발자 눈에 "Base64네" 하고 바로 보여요. ey 는 {" 를 Base64로 바꾸면 나오는 접두어라서, JSON을 Base64로 감싼 값은 거의 다 eyJ 로 시작해요. JWT가 모두 eyJ 로 시작하는 이유이기도 해요.
인코딩·암호화·해시 차이 한눈에 보기
세 가지는 목적이 완전히 달라요. 같은 입력 password123 을 넣어 보면 차이가 선명해져요.
| 구분 | 인코딩 (Base64) | 암호화 (AES-GCM) | 해시 (SHA-256, scrypt) |
|---|---|---|---|
| 목적 | 데이터를 다른 형식으로 안전하게 운반 | 키 없는 사람에게 내용 숨김 | 원문 없이 일치 여부 확인, 변조 검사 |
| 키 | 없음 | 있음 (키 관리가 핵심) | 없음 (비밀번호용은 솔트 사용) |
| 되돌리기 | 누구나 가능 | 키가 있어야 가능 | 불가능 (설계상 일방향) |
| 출력 길이 | 입력의 약 4/3 | 입력 + IV·태그 | 입력과 무관하게 고정 |
| 대표 용도 | 이메일 첨부, data URI, JWT 포장 | DB 민감 컬럼, 파일·백업 | 비밀번호 저장, 파일 무결성 |
헷갈릴 때는 "되돌릴 수 있는가, 그렇다면 누가?"만 물어보면 돼요. 누구나 되돌리면 인코딩, 키 가진 사람만 되돌리면 암호화, 아무도 못 되돌리면 해시예요.
그럼 Base64는 왜 쓰나 — 텍스트 통로로 바이너리 보내기
Base64가 쓸모없다는 얘기는 절대 아니에요. 원래 목적이 보안이 아닐 뿐이에요. 이메일, JSON, URL, HTTP 헤더처럼 텍스트만 다닐 수 있는 통로에 이미지·암호문 같은 바이너리를 실어 보내야 할 때가 있어요. 바이너리 안에는 줄바꿈이나 제어 문자로 해석되는 바이트가 섞여 있어서 그대로 넣으면 중간에 깨지거든요. Base64는 3바이트를 안전한 글자 4개로 바꿔 이 문제를 풀어요.
그래서 Base64는 늘 포장재예요. 안에 든 게 평문이면 평문이 보이고, 암호문이면 암호문이 보여요. 실제로 어디에 쓰이는지는 Base64가 쓰이는 곳 5가지 글에서 data URI, 이메일, PEM 인증서까지 자세히 다뤘어요.
암호화 결과를 Base64로 바꾸는 건 정상이다
"암호화한 결과를 Base64로 바꾸는 코드를 봤는데, 그건 뭐예요?"라는 질문을 자주 받아요. 그건 아주 정상적인 패턴이에요. 암호문은 바이너리라 DB 텍스트 컬럼이나 JSON에 그대로 넣기 어려워서 Base64로 포장하는 거예요. 보안은 암호화가 담당하고, Base64는 운반만 해요.
Node.js 내장 crypto 로 AES-256-GCM 암호화를 하면 이렇게 돼요.
const crypto = require('node:crypto');
function encrypt(plain, key) {
const iv = crypto.randomBytes(12); // 매번 새 IV
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
return Buffer.concat([iv, c.getAuthTag(), ct]).toString('base64');
}
function decrypt(b64, key) {
const buf = Buffer.from(b64, 'base64');
const d = crypto.createDecipheriv('aes-256-gcm', key, buf.subarray(0, 12));
d.setAuthTag(buf.subarray(12, 28));
return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
}
const key = crypto.randomBytes(32); // 실제로는 KMS·환경 변수에서
const token = encrypt('password123', key);
console.log(token); // hk/668GXlZWhk0QCW0Uslt5ahRWLjsSLKX80LAa36MRVc6/TNN4d
console.log(decrypt(token, key)); // password123
decrypt(token, crypto.randomBytes(32));
// Error: Unsupported state or unable to authenticate data
이 출력도 Base64라서 누구나 디코딩은 할 수 있어요. 하지만 디코딩해 봐야 나오는 건 의미 없는 바이트(IV + 인증 태그 + 암호문)뿐이에요. 키가 다르면 Unsupported state or unable to authenticate data 에러로 복호화 자체가 거부되고요. GCM 모드는 암호문이 1비트라도 바뀌면 같은 에러를 내서 변조도 함께 막아 줘요.
주의할 점은 키를 코드에 하드코딩하면 Base64와 다를 게 없다는 거예요. 프런트엔드 번들에 들어간 키는 누구나 볼 수 있어요. 키는 서버의 환경 변수나 클라우드 KMS에 두고, 같은 키로 IV를 재사용하지 마세요.
비밀번호 저장은 해시 — 그것도 느린 해시로
비밀번호는 서버도 원문을 알 필요가 없어요. 로그인할 때 "입력값이 저장된 값과 일치하는가"만 확인하면 되니까요. 그래서 되돌릴 수 없는 해시로 저장해요. 그런데 SHA-256 같은 범용 해시는 너무 빨라서 비밀번호용으로 부적합해요. 같은 환경에서 초당 처리량을 재 봤어요.
| 작업 | 초당 처리 횟수 (1스레드) | 공격자 관점 |
|---|---|---|
| Base64 디코딩 | 약 366만 회 | 사실상 평문 |
| SHA-256 해시 1회 | 약 88만 회 | 흔한 비밀번호는 금방 대입됨 |
| scrypt (N=16384, r=8, p=1) | 약 11회 | 한 번 시도에 수십 ms |
Apple M1, Node.js 22.18에서 0.5~2초씩 반복 실행해 직접 잰 값이에요. 다른 작업이 함께 돌던 환경이라 절대값은 흔들리지만, 자릿수 차이는 분명해요. 유출된 DB를 손에 넣은 공격자는 사전에 있는 비밀번호를 하나씩 해시해서 비교하는데, SHA-256이면 GPU로 초당 수십억 회까지 가능하고 scrypt·Argon2는 메모리까지 많이 쓰게 만들어서 이런 대입을 비싸게 만들어요.
const crypto = require('node:crypto');
const P = { N: 16384, r: 8, p: 1 };
function hashPassword(pw) {
const salt = crypto.randomBytes(16); // 사용자마다 다른 솔트
const hash = crypto.scryptSync(pw, salt, 32, P);
return `scrypt$${salt.toString('base64')}$${hash.toString('base64')}`;
}
function verifyPassword(pw, stored) {
const [, s, h] = stored.split('$');
const hash = crypto.scryptSync(pw, Buffer.from(s, 'base64'), 32, P);
return crypto.timingSafeEqual(hash, Buffer.from(h, 'base64'));
}
const stored = hashPassword('password123');
// scrypt$lhalvo0kHl4LYn5fxAs0rg==$VbAHgIcY8+r5n+t6Wze7zU/zII2R+cxh/hG4kLGKqQY=
verifyPassword('password123', stored); // true
verifyPassword('x', stored); // false
여기서도 솔트와 해시를 저장할 때 Base64를 써요. 포장재로서 Base64의 올바른 사용 예예요. 실무에서는 직접 구현하기보다 검증된 라이브러리(Argon2id, bcrypt)를 쓰는 게 좋고, OWASP 비밀번호 저장 가이드도 Argon2id를 1순위로 권장해요. 핵심은 세 가지예요. 느린 해시, 사용자별 솔트, 상수 시간 비교.
출력 길이로 보는 세 방식의 차이
같은 11바이트 입력이 각 방식을 거치면 길이가 어떻게 되는지도 비교해 봤어요. DB 컬럼 크기를 정할 때 실제로 필요한 숫자예요.
Base64는 입력에 비례해 약 4/3배로 늘고, 해시는 입력이 1글자든 1GB든 길이가 고정이에요. AES-GCM 결과는 입력 길이 + 28바이트(IV 12 + 태그 16)를 Base64로 포장한 길이라, 긴 데이터를 암호화할수록 오버헤드 비중이 작아져요.
Base64를 보안으로 착각하는 실무 사례
현업에서 실제로 마주치는 착각들을 모아 보면 이래요.
- 쿠버네티스 Secret: 매니페스트의
data:값은 Base64일 뿐이에요.kubectl get secret -o yaml권한만 있으면 누구나 원문을 봐요. etcd 저장 암호화(encryption at rest)와 RBAC 권한 제한이 진짜 보호 장치예요. - HTTP Basic 인증:
Authorization: Basic dXNlcjpwYXNz는user:pass를 Base64로 바꾼 거예요. HTTPS 없이 쓰면 네트워크에서 그대로 노출돼요. - JWT 페이로드: 서명은 변조를 막을 뿐 내용을 숨기지 않아요. JWT 디코더에 넣으면 누구나 클레임을 읽어요. 주민번호, 전화번호 같은 개인정보를 넣으면 안 돼요.
- 프런트엔드 API 키 "난독화": 번들에 Base64로 넣은 키는 개발자 도구에서 1분이면 찾아요. 비밀 키는 서버에만 두세요.
- URL 파라미터의 사용자 ID:
?u=MTIzNA==를 디코딩하면1234. 숫자만 바꿔 다른 사람 정보를 조회하는 IDOR 취약점의 단골 패턴이에요. 서버에서 권한을 검사해야 해요.
난독화 용도로 쓰는 것 자체는 괜찮아요. 로그에서 사람 눈에 안 띄게 하거나, 특수문자를 피하려는 목적이라면요. 다만 "이걸로 보안이 확보됐다"고 문서에 쓰는 순간 위험해져요. 다음 사람이 그 문장을 믿고 그 위에 다른 걸 쌓으니까요.
상황별로 무엇을 써야 하나
| 하고 싶은 것 | 써야 하는 것 | 쓰면 안 되는 것 |
|---|---|---|
| 비밀번호 저장 | Argon2id, bcrypt, scrypt | Base64, 암호화, SHA-256 단독 |
| 개인정보 컬럼 저장 | AES-GCM 등 + KMS 키 관리 | Base64 |
| 네트워크 구간 보호 | TLS (HTTPS) | Base64로 감싼 평문 |
| 바이너리를 JSON·URL로 운반 | Base64 / Base64URL | 직접 바이트 삽입 |
| 파일 변조 확인 | SHA-256 체크섬, 서명 | Base64 |
| 토큰 위·변조 방지 | HMAC, 전자서명 (JWT 서명) | Base64만 한 토큰 |
자주 묻는 질문
Base64로 인코딩하면 보안상 안전한가요?
아니요. Base64는 공개된 문자표로 변환하는 인코딩이라 키 없이 누구나 즉시 원문으로 되돌릴 수 있어요. 비밀번호, API 키, 개인정보를 보호하는 효과는 전혀 없어요. 숨겨야 한다면 암호화를, 비밀번호라면 느린 해시를 쓰세요.
암호화와 해시의 차이는 무엇인가요?
암호화는 키가 있으면 원문으로 되돌릴 수 있는 양방향 변환이고, 해시는 누구도 되돌릴 수 없는 일방향 변환이에요. 나중에 원문이 필요한 데이터(주소, 전화번호)는 암호화하고, 일치 여부만 확인하면 되는 데이터(비밀번호)는 해시해요.
비밀번호를 SHA-256으로 해시하면 안 되나요?
SHA-256 단독은 너무 빨라서 비밀번호 저장용으로 부적합해요. 유출되면 사전 대입 공격으로 흔한 비밀번호가 금방 풀려요. Argon2id, bcrypt, scrypt처럼 일부러 느리고 솔트를 쓰는 비밀번호 전용 해시를 쓰세요.
JWT는 Base64인데 그럼 내용이 다 보이나요?
네, 헤더와 페이로드는 Base64URL로 인코딩만 돼 있어서 누구나 읽을 수 있어요. 서명은 내용이 바뀌지 않았다는 걸 보증할 뿐 숨겨 주지 않아요. 민감한 정보는 넣지 말고, 꼭 필요하면 JWE(암호화된 JWT)를 검토하세요.
암호문을 Base64로 저장하는 건 괜찮은가요?
네, 정상적인 방법이에요. 암호문은 바이너리라 텍스트 컬럼이나 JSON에 넣으려면 Base64 같은 인코딩이 필요해요. 보안은 암호화와 키 관리가 담당하고 Base64는 표현 형식일 뿐이에요.
정리
Base64는 텍스트 통로에 바이너리를 싣기 위한 포장재예요. 키가 없으니 누구나 풀 수 있고, 비밀을 지켜 주지 않아요. 내용을 숨기려면 암호화(키 관리 포함), 비밀번호는 느린 해시, 네트워크는 TLS. 그리고 그 결과물을 텍스트로 운반할 때 Base64를 쓰면 돼요.
Base64 값이 무엇을 담고 있는지 궁금하면 Base64 인코더/디코더로 바로 풀어 보세요. URL에 넣을 때 +, /, = 가 왜 문제인지는 URL-safe Base64와 패딩, JWT 서명이 실제로 무엇을 보장하는지는 JWT 구조와 서명 원리에서 이어서 볼 수 있어요.