서로 다른 세 가지 연산
사람들이 보호를 기대하는 곳에 Base64가 자주 등장하므로, 무엇을 하는지 정확히 짚어 둘 필요가 있습니다. Base64는 인코딩, 즉 바이트를 텍스트로 바꾸는 고정되고 공개된 대응 규칙입니다(Base64의 원리 참고). 이 대응에는 키도 비밀도 없으며, 같은 입력은 항상 같은 출력을 만듭니다.
| 인코딩 (Base64, hex) | 암호화 (AES-GCM) | 해시 (SHA-256, Argon2) | |
|---|---|---|---|
| 목적 | 바이트를 텍스트로 표현 | 데이터 기밀 유지 | 데이터 지문 또는 검증 |
| 되돌릴 수 있음 | 예, 누구나 | 예, 키가 있으면 | 아니요 |
| 키 필요 | 아니요 | 예 | 아니요 (HMAC은 사용) |
| 같은 입력, 같은 출력 | 항상 | 아니요, 무작위 nonce 사용 시 | 예, 솔트를 쓰지 않으면 |
| 출력 크기 | 입력의 약 4/3 | 입력에 nonce와 태그 추가 | 고정 길이 |
디코딩에는 아무런 노력도 필요 없습니다.
echo ZGVwbG95Omh1bnRlcjI= | base64 -d
# deploy:hunter2
이 문자열은 흘끗 보는 사람에게는 알 수 없는 문자열처럼 보입니다. 스크립트, 로그 스캐너, 공격자에게는 단계 하나가 더 붙은 평문입니다.
Base64 데이터가 실제로 새는 방식
패턴은 늘 같습니다. 전달을 위해 값을 Base64로 인코딩하고, 누군가 그것이 보호된 것이라고 착각하고, 결국 읽힐 수 있는 곳에 놓이게 됩니다. 아래는 이런 실수가 흔히 나타나는 모습입니다.
HTTP Basic Auth 헤더
Basic 인증(RFC 7617)은 모든 요청에 사용자 이름과 비밀번호를 Base64(user:password)로 보냅니다.
Authorization: Basic ZGVwbG95Omh1bnRlcjI=
유일한 보호 수단은 연결의 TLS입니다. 헤더가 그 터널을 벗어나는 순간 비밀번호는 사실상 평문입니다. 흔한 경로는 요청 헤더를 로그로 남기는 리버스 프록시나 애플리케이션 프레임워크, 브라우저 개발자 도구에서 내보내 지원 티켓에 첨부한 HAR 파일, 채팅에 붙여넣은 curl -v 출력, 전체 요청을 수집하는 오류 추적 도구입니다. Basic Auth 디코더를 보면 하나를 읽는 데 얼마나 적은 노력이 드는지 알 수 있습니다.
Kubernetes Secret
Kubernetes Secret은 값을 Base64로 인코딩해 저장합니다.
apiVersion: v1
kind: Secret
metadata:
name: db
data:
password: czNjcjN0
czNjcjN0은 s3cr3t입니다. 이 인코딩은 YAML 안에서 값이 바이너리 데이터를 담을 수 있게 하려고 존재합니다. Kubernetes 문서는 Secret이 기본적으로 etcd에 암호화되지 않은 채 저장되며, API로 Secret을 get할 수 있는 사람은 누구나 읽을 수 있다고 명시합니다. 흔한 유출 경로는 실제 data: 값이 든 매니페스트를 Git 저장소에 커밋하는 것, kubectl get secret -o yaml 출력을 티켓에 붙여넣는 것, etcd나 클러스터 상태의 백업을 암호화 없이 보관하는 것입니다. Kubernetes Secret 디코더는 Secret의 모든 값을 한 번에 디코딩하며, 매니페스트가 실제로 무엇을 노출하는지 빠르게 확인하는 방법이기도 합니다.
컨테이너 레지스트리 자격 증명도 같은 패턴입니다. Docker config.json이나 .dockerconfigjson Secret의 auth 필드는 Base64(user:password)이며, dockerconfigjson 디코더가 이를 읽습니다.
JWT 페이로드
서명된 JWT는 봉인된 토큰처럼 보이지만, 처음 두 부분은 그냥 Base64URL JSON입니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImVtYWlsIjoiYW5hQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIn0.<signature>
페이로드는 {"sub":"42","email":"ana@example.com","role":"admin"}으로 디코딩됩니다. 서명은 토큰의 변조를 막아 줄 뿐 기밀성에는 아무 역할도 하지 않습니다. 토큰을 보는 모든 브라우저 확장 프로그램, 프록시, 로그 파일, 분석 스크립트가 안의 내용을 읽을 수 있습니다. 개인정보, 드러내고 싶지 않은 내부 ID, 비밀인 모든 것은 JWT 페이로드에 들어가서는 안 됩니다. 꼭 토큰에 실어 보내야 한다면 암호화된 JWT(JWE)를 쓰세요. 자세한 내용은 JWT 속 Base64에 있습니다.
코드와 설정 속 “난독화된” 시크릿
프런트엔드 JavaScript, 모바일 앱 번들, 설정 파일에 Base64로 저장한 API 키는 고전적인 사례입니다. 흔한 이유는 키가 “평문"이 되지 않게 한다는 것입니다. 실제로 막아 주는 것은 키 문자열을 그대로 찾는 단순 검색에 걸리지 않는 것, 딱 그것뿐입니다. 클라이언트로 배포된 것은 무엇이든 그 클라이언트가 읽을 수 있습니다.
같은 발상은 공격에서 반대 방향으로도 쓰입니다. PowerShell의 -EncodedCommand는 UTF-16LE 텍스트의 Base64를 받으며, 악성 명령을 한눈에 읽기 어렵게 만드는 데 자주 쓰입니다. 방어하는 쪽은 PowerShell 인코딩 명령 디코더 같은 도구로 몇 초 만에 디코딩합니다.
대신 써야 할 것
목적에 따라 도구를 고르세요. 아래 각 경우에도 Base64는 다른 무언가가 보호하는 바이트를 감싸는 텍스트 포장으로서 여전히 자리가 있습니다.
데이터 기밀 유지: 인증 암호화
잘 관리되는 라이브러리를 통해 AES-256-GCM이나 ChaCha20-Poly1305를 쓰세요. 둘 다 인증 암호화이므로 복호화할 때 변조가 감지됩니다. 알고리즘보다 중요한 규칙이 두 가지 있습니다. 같은 키로 nonce를 절대 재사용하지 말 것, 그리고 키를 데이터 옆이 아닌 다른 곳(KMS, 시크릿 관리 도구, 공격자가 함께 장악하지 못한 환경)에 둘 것.
import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
const key = randomBytes(32); // in real code, load this from a KMS or secret store
const iv = randomBytes(12); // 96-bit nonce, unique per message
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update("deploy:hunter2", "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
// Base64 carries the encrypted bytes as text: nonce + tag + ciphertext
const token = Buffer.concat([iv, tag, ct]).toString("base64");
const raw = Buffer.from(token, "base64");
const d = createDecipheriv("aes-256-gcm", key, raw.subarray(0, 12));
d.setAuthTag(raw.subarray(12, 28));
console.log(Buffer.concat([d.update(raw.subarray(28)), d.final()]).toString("utf8"));
// deploy:hunter2
출력은 56자 Base64 문자열이며, nonce가 무작위이므로 실행할 때마다 바뀝니다. 키 없이는 쓸모가 없습니다. 코드를 쓰지 않고 실험해 보려면 브라우저에서 실행되는 AES 암호화와 AES 복호화 도구를 쓰세요.
비밀번호 저장: 느리고 솔트를 쓰는 해시
비밀번호는 암호화하거나 인코딩해서는 안 됩니다. 비밀번호용으로 설계된 함수, 즉 Argon2id, scrypt, bcrypt로 비밀번호마다 고유한 무작위 솔트를 써서 만든 해시를 저장하세요. 비밀번호의 단순 SHA-256은 계산이 너무 빨라서 무차별 대입에 뚫립니다. Python 표준 라이브러리에는 scrypt가 있습니다.
import hashlib, hmac, os
def hash_password(pw: str) -> bytes:
salt = os.urandom(16)
return salt + hashlib.scrypt(pw.encode(), salt=salt, n=2**14, r=8, p=1)
def check_password(pw: str, stored: bytes) -> bool:
salt, digest = stored[:16], stored[16:]
candidate = hashlib.scrypt(pw.encode(), salt=salt, n=2**14, r=8, p=1)
return hmac.compare_digest(candidate, digest)
결과를 텍스트 컬럼이나 JSON에 넣어야 한다면 Base64로 인코딩하세요.
무결성 증명: 서명과 HMAC
목표가 “아무도 읽을 수 없게"가 아니라 “아무도 바꾸지 못했음"이라면 공유 시크릿을 쓰는 HMAC이나 디지털 서명을 쓰세요. 결과는 바이트이며, 보통 Base64나 hex로 전달합니다. HMAC 생성기는 출력을 두 형태로 모두 보여 줍니다.
전송 중이거나 저장된 자격 증명
Basic Auth나 토큰을 실어 나르는 모든 연결에 TLS를 쓰고, 고정 비밀번호보다 수명이 짧은 토큰을 택하고, 로그에서 Authorization 헤더를 지우고, Kubernetes Secret의 저장 시 암호화를 켜고, 실제 시크릿 값은 Git 밖에 두세요.
자주 묻는 질문
Base64는 안전한가요?
Base64에는 보안 속성이 없습니다. 키가 없으므로 디코딩에는 문자열 자체 외에 아무것도 필요 없습니다. 암호문이나 서명처럼 이미 보호된 데이터를 전달하는 형식으로는 안전하게 쓸 수 있습니다.
설정 파일의 비밀번호를 Base64로 숨기면 충분한가요?
아니요. 파일을 읽을 수 있는 사람은 누구나 base64 -d로 비밀번호를 디코딩할 수 있습니다. 파일 권한을 제한하거나, 시크릿 관리 도구를 쓰거나, 다른 곳에 보관한 키로 값을 암호화하세요.
보안용이 아니라면 Kubernetes Secret은 왜 Base64로 인코딩되나요?
TLS 키나 키스토어 같은 임의의 바이너리 데이터를 YAML과 JSON 안의 Secret 값에 담기 위해서입니다. 보호는 RBAC, etcd의 저장 시 암호화, 매니페스트의 신중한 관리에서 나옵니다.
시크릿 없이도 누구나 JWT 페이로드를 읽을 수 있나요?
네. 헤더와 페이로드는 누구나 읽을 수 있는 Base64URL JSON입니다. 키는 서명을 검증하거나 만들 때만 필요합니다. JWT 디코더로 직접 확인해 보세요.
인코딩, 암호화, 해시의 차이는 무엇인가요?
인코딩은 누구나 되돌릴 수 있습니다. 암호화는 키가 있어야만 되돌릴 수 있습니다. 해시는 일방향이어서 값을 해시와 대조할 수는 있지만 해시를 값으로 되돌릴 수는 없습니다.