표 하나로 보는 차이
두 변형 모두 RFC 4648에 정의되어 있습니다. 4절은 표준 Base64를, 5절은 “URL and Filename safe” 알파벳을 설명합니다. 62개 문자와 알고리즘은 같고, 인덱스 62와 63만 다릅니다.
| 표준 Base64 (RFC 4648 4절) | Base64URL (RFC 4648 5절) | |
|---|---|---|
| 인덱스 0–61 | A–Z a–z 0–9 | 같음 |
| 인덱스 62 | + | - |
| 인덱스 63 | / | _ |
| 패딩 | 기본적으로 = 필수 | 보통 생략 |
| URL 경로나 쿼리에 안전 | 아니요 | 예 |
| 파일 이름으로 안전 | 아니요 (/는 경로 구분자) | 예 |
같은 입력을 두 방식으로 인코딩하면 다음과 같습니다.
| 입력 | 표준 | Base64URL | Base64URL, 패딩 없음 |
|---|---|---|---|
<<???>> | PDw/Pz8+Pg== | PDw_Pz8-Pg== | PDw_Pz8-Pg |
바이트 fb ff | +/8= | -_8= | -_8 |
Man | TWFu | TWFu | TWFu |
마지막 행에서 보듯 많은 문자열은 두 변형에서 똑같아 보입니다. +, /, -, _가 들어 있거나 패딩이 빠져 있을 때만 구분할 수 있습니다. 구분할 수 없다면 두 알파벳을 모두 받는 디코더를 쓰는 것이 안전합니다.
받은 문자열을 빠르게 알아보는 방법은 다음과 같습니다.
-나_가 있습니다: Base64URL.+나/가 있습니다: 표준 Base64.- 길이가 4의 배수가 아니고
=가 없습니다: 패딩이 없는 것이며, 거의 언제나 Base64URL입니다. +와-가 함께 있거나/와_가 함께 있습니다: 무언가가 알파벳을 섞었거나 데이터가 아예 Base64가 아닙니다. Base64 검사기가 어떤 문자가 잘못되었는지 보여 줍니다.- 점으로 나뉜 세 부분이
eyJ로 시작합니다: 정의상 Base64URL인 JWT입니다.
Base64URL이 존재하는 이유
URL에서 /는 경로 구간을 나누고, +는 폼 인코딩된 쿼리 문자열에서 공백을 뜻하며, =는 키와 값을 나눕니다. 표준 Base64는 이 세 문자를 모두 씁니다. URL에 넣으려면 퍼센트 인코딩을 해야 하므로 ab+/8=가 ab%2B%2F8%3D가 되어, 더 길고, 보기 싫고, 틀릴 수 있는 단계가 하나 더 생깁니다.
RFC 4648 5절은 URL에서 예약되지 않았고 흔히 쓰는 모든 파일 시스템에서 파일 이름으로 쓸 수 있는 -와 _를 골라 이를 해결합니다. RFC는 길이를 알 수 있다면 패딩을 생략해도 된다는 점도 언급하는데, = 자체도 %3D로 이스케이프해야 하기 때문입니다. 자세한 내용은 패딩 가이드에 있습니다.
각 변형이 쓰이는 곳
표준 Base64:
- MIME 이메일 첨부 파일과 인코딩된 헤더(이메일 속 Base64)
- PEM 인증서와 키
- HTML과 CSS의
data:URI - HTTP Basic Auth 헤더
- Kubernetes Secret과 파일을 담는 대부분의 JSON API
Base64URL, 보통 패딩 없이:
- JSON Web Token: 헤더, 페이로드, 서명 모두 패딩 없는 Base64URL(JWT 속 Base64)
- JWK 키, JWS와 JWE 전반(RFC 7515–7518)
- WebAuthn 자격 증명 ID와 챌린지
- OAuth PKCE
code_challenge값(RFC 7636) - Python의
secrets.token_urlsafe()같은 무작위 URL 토큰. 무작위 32바이트에 대해 43자 문자열을 돌려줬습니다
서로 변환하기
변환은 순수한 문자 치환이며 패딩만 따로 처리합니다. 바이트는 절대 바뀌지 않습니다.
표준에서 URL-safe로: +를 -로, /를 _로 바꾸고 끝의 =를 지웁니다.
URL-safe에서 표준으로: -를 +로, _를 /로 바꾸고 길이가 4의 배수가 될 때까지 =를 붙입니다.
const toUrl = (b64) =>
b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const fromUrl = (u) => {
const b64 = u.replace(/-/g, "+").replace(/_/g, "/");
return b64 + "=".repeat((4 - (b64.length % 4)) % 4);
};
toUrl("PDw/Pz8+Pg=="); // "PDw_Pz8-Pg"
fromUrl("PDw_Pz8-Pg"); // "PDw/Pz8+Pg=="
대부분의 언어에는 전용 인코더도 있으므로 문자열을 직접 주무를 일은 드뭅니다.
| 언어 | Base64URL 인코더 | 패딩 |
|---|---|---|
| Python | base64.urlsafe_b64encode(b) | 유지, .rstrip(b"=")로 제거 |
| Node.js | buf.toString("base64url") | 생략 |
| Go | base64.RawURLEncoding / base64.URLEncoding | 생략 / 유지 |
| Java | Base64.getUrlEncoder().withoutPadding() | 생략 |
| PHP | rtrim(strtr(base64_encode($s), '+/', '-_'), '=') | 생략 |
디코딩할 때 Node의 Buffer.from(s, "base64")는 두 알파벳과 빠진 패딩을 모두 받습니다. Python의 base64.urlsafe_b64decode는 먼저 패딩을 복원해야 합니다. 브라우저의 atob()는 -와 _를 InvalidCharacterError로 거부하므로, 먼저 변환하거나 지원되는 환경에서는 alphabet: "base64url"과 함께 Uint8Array.fromBase64를 쓰세요. 다른 언어의 코드는 코드 페이지에 있으며, Python과 Java도 있습니다.
문자열 하나만 바꾸면 된다면 Base64를 Base64URL로와 Base64URL을 Base64로가 브라우저에서 처리해 줍니다.
함정
+가 공백으로 바뀝니다
가장 흔한 버그입니다. 이스케이프 없이 쿼리 문자열에 넣은 표준 Base64는 +를 공백으로 읽는 폼 디코딩 때문에 망가집니다.
from urllib.parse import parse_qs
parse_qs("t=ab+/8=")
# {'t': ['ab /8=']}
new URLSearchParams("t=ab+/8=").get("t");
// "ab /8="
그러면 뒤쪽의 디코더가 공백에서 실패하거나, 공백을 건너뛰고 틀린 바이트를 돌려줍니다. 해결책은 Base64URL을 쓰거나 넣을 때 encodeURIComponent()를 쓰는 것입니다. 받는 쪽에서 급히 고치려면 공백을 다시 +로 바꾸면 대개 됩니다. Base64에는 공백이 없기 때문입니다.
알파벳이 조용히 섞입니다
Python의 base64.b64decode는 validate=True를 넘기지 않으면 표준 알파벳 밖의 문자를 버립니다. Base64URL 문자열을 넣으면 -와 _를 버리고 나머지를 디코딩합니다.
import base64
base64.b64decode("PDw_Pz8-Pg==")
# b'<<\x0f\xcf\xc3\xe0' (wrong; the input was "<<???>>")
base64.b64decode("PDw_Pz8-Pg==", validate=True)
# binascii.Error: Only base64 data is allowed
base64.urlsafe_b64decode("PDw_Pz8-Pg==")
# b'<<???>>'
첫 번째 호출은 오류 없이 잘못된 바이트를 반환합니다. URL-safe 입력에는 urlsafe_b64decode를 쓰거나, validate=True로 불일치를 드러내세요.
이중 인코딩
이 이름으로 불리는 서로 다른 두 가지가 있고, 둘 다 실제로 일어납니다.
퍼센트 인코딩을 두 번. 표준 Base64 문자열을 코드에서 한 번, HTTP 클라이언트가 또 한 번 이스케이프하면 +가 %2B가 되었다가 %252B가 됩니다. 서버는 한 번만 디코딩하므로 문자 그대로의 %2B를 받습니다. Base64URL은 이스케이프할 것이 없으므로 이 문제가 없습니다.
Base64의 Base64. 애플리케이션이 한 번, 프레임워크나 Kubernetes 매니페스트 도구가 또 한 번처럼 값이 두 층에서 인코딩됩니다. Man은 TWFu가 되고, 다시 VFdGdQ==가 됩니다. 디코딩한 결과가 여전히 Base64 문자로만 되어 있다면 이 경우입니다. 한 번 더 디코딩해서 결과가 말이 되는지 확인하세요.
패딩에 대한 기대
패딩을 요구하는 수신 측도 있고 거부하는 수신 측도 있습니다. Go의 RawURLEncoding은 =가 든 입력에서 실패하고, Python의 디코더는 =가 없는 입력에서 실패합니다. API를 정의할 때는 문서에 변형과 패딩 규칙을 명시하고, 자신의 디코더는 두 가지를 모두 받게 만드세요.
패딩이 있든 없든 URL-safe 문자열을 붙여넣고 안에 무엇이 있는지 확인하세요.
자주 묻는 질문
Base64URL은 Base64 문자열을 URL 인코딩한 것과 같은가요?
아니요. 퍼센트 인코딩은 +를 %2B로, /를 %2F로 바꾸므로 문자열이 길어집니다. Base64URL은 다른 알파벳을 써서 이스케이프가 아예 필요 없습니다.
표준 Base64 디코더로 Base64URL을 읽을 수 있나요?
확실하지 않습니다. Node의 Buffer처럼 두 알파벳을 모두 받는 디코더도 있습니다. -와 _를 거부하는 디코더도 있고, Python의 b64decode는 기본적으로 이를 조용히 버려서 틀린 바이트를 만듭니다.
Base64URL에는 패딩이 있나요?
RFC 4648은 허용하고, Python의 urlsafe_b64encode도 패딩을 유지합니다. JWT를 비롯해 Base64URL을 쓰는 대부분의 형식은 길이로 이미 알 수 있으므로 =를 뺍니다.
URL 속 토큰에는 무엇을 써야 하나요?
패딩 없는 Base64URL입니다. 경로, 쿼리 문자열, 쿠키, 파일 이름 어디서도 이스케이프가 필요 없습니다.