~ / posts / developer
smith@lab:~/posts$ cat developer/url-safe-base64-padding.md

URL-safe Base64(Base64URL) 차이와 패딩 = 복원, Incorrect padding 해결

예전에 이메일 인증 링크가 일부 사용자에게만 "유효하지 않은 토큰"으로 뜨는 버그를 잡은 적이 있어요. 재현이 안 돼서 한참 헤맸는데, 원인은 토큰 안의 + 한 글자였어요. 표준 Base64로 만든 토큰을 URL 쿼리에 그대로 붙였고, 서버에서 쿼리를 파싱할 때 + 가 공백으로 바뀌어 들어왔던 거죠.

결론부터 말하면 URL, 파일명, 쿠키에 넣을 값은 처음부터 Base64URL(+ → -, / → _, 패딩 = 생략)로 만들고, 디코딩할 때는 길이를 4의 배수로 맞추도록 = 를 다시 붙이면 돼요. 이번 글에서는 왜 "일부 사용자만" 실패하는지 확률로 계산해 보고, 언어별 변환 코드와 검색하게 되는 에러 메시지(Incorrect padding, InvalidCharacterError, Illegal base64 character 2d)의 원인을 정리했어요.

핵심 요약

표준 Base64와 Base64URL은 62·63번 문자(+ / 대신 - _)와 패딩 처리만 달라요.

32바이트 랜덤 토큰은 약 74% 확률로 + 나 / 를 포함해서, 표준 Base64를 URL에 넣으면 일부 토큰만 깨지는 버그가 생겨요.

패딩 복원은 길이를 4로 나눈 나머지가 2면 ==, 3이면 = 를 붙이고, 나머지가 1이면 잘린 데이터예요.

Python b64decode는 기본값에서 - _ 를 조용히 버려 엉뚱한 바이트를 돌려줄 수 있으니 urlsafe_b64decode를 쓰세요.

표준 Base64의 + / = 가 URL에서 문제인 이유

표준 Base64(RFC 4648 4절)는 대소문자 52개, 숫자 10개, 그리고 + 와 / 를 써요. 문제는 이 두 개와 패딩 = 예요.

실제로 확인해 보면 파이썬과 브라우저 모두 쿼리의 + 를 공백으로 바꿔요.

from urllib.parse import parse_qs
parse_qs('t=ab+cd%2Bef')   # {'t': ['ab cd+ef']}  ← 생 + 는 공백, %2B 만 +
new URLSearchParams('t=ab+cd').get('t');  // 'ab cd'

퍼센트 인코딩(%2B, %2F, %3D)으로 감싸면 안전하게 실을 수는 있어요. 하지만 글자 하나가 세 글자가 돼서 길어지고, 어딘가에서 이중 인코딩이나 디코딩 누락이 생기기 쉬워요. URL 인코더로 직접 바꿔 보면 ab+/= 가 ab%2B%2F%3D 로 두 배 이상 늘어나는 걸 볼 수 있어요.

Base64URL은 세 글자만 바꾼 것

그래서 같은 RFC 5절에 URL과 파일명에 안전한 변형이 정의돼 있어요.

표준 Base64와 Base64URL의 62번, 63번 문자와 패딩 처리 차이, 그리고 길이에 따른 패딩 복원 규칙
다른 건 +, /, = 세 글자뿐
구분표준 Base64Base64URL
62번째 문자+-
63번째 문자/_
패딩 =붙임 (필수인 구현 많음)생략하는 경우가 많음 (JWT는 생략 필수)
대표 사용처이메일, PEM, data URIJWT, URL 토큰, 파일명
Node.jstoString('base64')toString('base64url')
Pythonb64encodeurlsafe_b64encode (패딩은 남김)

나머지 62글자는 같아요. 그래서 대부분의 문자열은 두 방식에서 똑같이 생겼고, 하필 62·63번 값이 나올 때만 차이가 나요. 테스트에서 안 잡히고 운영에서 가끔 터지는 이유가 바로 이거예요.

일부 토큰만 깨지는 이유 — 확률로 계산해 보기

"가끔"이 얼마나 가끔인지 직접 재 봤어요. 길이별로 랜덤 바이트 2만 개씩 표준 Base64로 인코딩해서 + 나 / 가 하나라도 들어간 비율을 셌어요.

랜덤 토큰을 표준 Base64로 만들었을 때 + 또는 / 가 들어갈 확률
02550751006B9B12B16B24B32B48B64B6B · 포함 확률 22.9%9B · 포함 확률 31.6%12B · 포함 확률 39.5%16B · 포함 확률 48.6%24B · 포함 확률 64.1%32B · 포함 확률 73.6%48B · 포함 확률 86.9%64B · 포함 확률 93.4%93.4

흔히 쓰는 32바이트 토큰이면 4개 중 3개에 문제 문자가 있다

단위: % · 자료: Python 3.9 os.urandom 으로 길이별 2만 개씩 생성해 직접 집계 (이론값 1-(62/64)^글자수 와 1%p 이내로 일치)
표로 보기
구분포함 확률
6B22.9
9B31.6
12B39.5
16B48.6
24B64.1
32B73.6
48B86.9
64B93.4

16바이트 토큰은 대략 절반, 32바이트 토큰은 74%가 문제 문자를 포함해요. 그중 + 만 공백으로 바뀌는 환경이라면 실패율은 그보다 조금 낮아지고, 그래서 "재현이 될 때도 있고 안 될 때도 있는" 버그가 돼요. 테스트용 토큰 몇 개로 확인하면 운 좋게 다 통과할 수도 있어요.

길이도 비교해 볼게요. 32바이트 토큰을 URL에 넣는 세 가지 방법이에요.

32바이트 토큰을 URL에 넣을 때 문자열 길이
표준 Base64 + 퍼센트 인코딩(평균)표준 Base64 + 퍼센트 인코딩(평균) · 48.6자48.6자표준 Base64표준 Base64 · 44자44자Base64URL 패딩 포함Base64URL 패딩 포함 · 44자44자Base64URL 패딩 생략Base64URL 패딩 생략 · 43자43자

퍼센트 인코딩은 길이가 토큰마다 달라진다

단위: 자 · 자료: Python 3.9 로 랜덤 32바이트 2만 개를 인코딩해 urllib.parse.quote(safe='') 후 평균 길이 측정
표로 보기
구분길이
표준 Base64 + 퍼센트 인코딩(평균)48.6
표준 Base6444
Base64URL 패딩 포함44
Base64URL 패딩 생략43

Base64URL에 패딩 생략이면 항상 43자로 고정돼요. DB 컬럼 길이나 로그 파싱 규칙을 정하기도 편하죠. 파이썬 secrets.token_urlsafe(32) 가 정확히 이 43자 Base64URL 토큰을 만들어 줘요.

언어별 변환 코드 (JS, Node, Python, Java)

브라우저 JavaScript

브라우저의 btoa/atob 는 표준 Base64만 알아요. 문자 치환과 패딩 처리를 직접 해야 해요.

function toBase64Url(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function fromBase64Url(s) {
  s = s.replace(/-/g, '+').replace(/_/g, '/');
  if (s.length % 4 === 1) throw new Error('잘린 Base64URL 문자열');
  s += '='.repeat((4 - (s.length % 4)) % 4);
  return Uint8Array.from(atob(s), c => c.charCodeAt(0));
}

const t = toBase64Url(new TextEncoder().encode('한글 토큰?'));
// '7ZWc6riAIO2GoO2BsD8'
new TextDecoder().decode(fromBase64Url(t)); // '한글 토큰?'

atob() 결과는 바이트를 한 글자씩 담은 문자열이라, 한글 같은 UTF-8 텍스트는 그대로 출력하면 깨져요. 위처럼 Uint8Array 로 바꾼 뒤 TextDecoder 로 한 번 더 풀어야 해요.

Node.js

Node.js(v15.7 이상, v14.18 이상)는 base64url 인코딩을 기본 지원해서 한 줄이면 끝나요.

Buffer.from('한글 토큰?').toString('base64url');            // '7ZWc6riAIO2GoO2BsD8'
Buffer.from('7ZWc6riAIO2GoO2BsD8', 'base64url').toString(); // '한글 토큰?'
require('node:crypto').randomBytes(32).toString('base64url'); // 43자 토큰

Python

import base64

def b64url_encode(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b'=').decode()

def b64url_decode(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))

-len(s) % 4 는 파이썬에서 "4의 배수까지 모자란 개수"를 구하는 관용구예요. 길이가 22면 2, 23이면 1, 24면 0이 나와요. 파이썬 urlsafe_b64encode 는 패딩을 남겨 두니 JWT처럼 패딩이 없어야 하는 곳에선 rstrip 이 필요해요.

Java

Java 8 이상은 Base64.getUrlEncoder().withoutPadding() 과 Base64.getUrlDecoder() 를 쓰면 돼요. Java 디코더는 패딩이 없어도 잘 받아 줘요.

패딩 복원 규칙과 언어별 에러 메시지

Base64 출력은 원래 항상 4의 배수 길이예요. 패딩을 뺀 문자열의 길이를 4로 나눈 나머지로 몇 개를 붙일지 정해져요.

길이 % 4원래 마지막 묶음붙일 패딩
03바이트없음
21바이트==
32바이트=
1불가능없음 — 중간에 잘린 데이터

나머지 1은 올바른 Base64에서 절대 나올 수 없어요. 그런 입력이 오면 복사하다 한 글자가 빠졌거나 URL이 잘린 거예요. 실제 런타임별 반응을 확인해 봤어요.

입력Python 3.9 b64decode브라우저·Node atobJava 17 getDecoder()
YQ (패딩 없음)binascii.Error: Incorrect padding"a" (허용)허용
a-b_ (URL 문자)조용히 버림 → 패딩 에러InvalidCharacterError: Invalid characterIllegalArgumentException: Illegal base64 character 2d
길이 % 4 = 1number of data characters (5) cannot be 1 more than a multiple of 4The string to be decoded is not correctly encoded.Last unit does not have enough valid bits

Java의 character 2d 는 16진수로 - 문자(0x2D)라는 뜻이에요. 이 메시지가 보이면 Base64URL 문자열을 표준 디코더에 넣은 거니 getUrlDecoder() 로 바꾸면 돼요. 5f 가 보이면 _ 예요.

가장 위험한 함정: Python b64decode의 조용한 실패

에러가 나면 차라리 다행이에요. 진짜 위험한 건 에러 없이 틀린 값이 나오는 경우예요. 파이썬 b64decode 는 기본값(validate=False)에서 Base64 문자표에 없는 문자를 조용히 버려요.

import base64
tok = base64.urlsafe_b64encode(b'\xfb\xef\xbe\x01\x02\x03').decode()
print(tok)                           # ----AQID
print(base64.b64decode(tok))         # b'\x01\x02\x03'   ← 앞 3바이트 증발, 에러 없음
print(base64.urlsafe_b64decode(tok)) # b'\xfb\xef\xbe\x01\x02\x03'  ← 정상
base64.b64decode(tok, validate=True) # binascii.Error: Non-base64 digit found

- 네 개가 버려지고 남은 AQID 가 정상 디코딩돼서 아무 경고 없이 엉뚱한 바이트가 나왔어요. 서명 검증이나 토큰 비교에 쓰이면 "가끔 검증 실패"라는 최악의 버그가 돼요. Node.js Buffer.from(s, 'base64') 도 두 문자표를 다 받아 주고 잘못된 문자를 무시하는 관대한 디코더라서, 입력 검증 용도로는 믿으면 안 돼요. 외부 입력을 디코딩할 땐 엄격 모드(validate=True)나 전용 URL 디코더를 쓰세요.

JWT가 대표적인 Base64URL 사용처

JWT의 헤더·페이로드·서명 세 부분은 모두 Base64URL로 인코딩되고 패딩을 빼요(RFC 7515). 그래서 JWT 페이로드를 표준 Base64 디코더에 넣으면 길이가 4의 배수가 아니라서 Incorrect padding 이 나거나, - 와 _ 를 모르는 디코더라면 엉뚱한 바이트가 나와요.

import json
payload = token.split('.')[1]
claims = json.loads(b64url_decode(payload))  # 위에서 만든 함수

JWT 디코더는 이 처리를 알아서 해 주지만, 직접 코드로 디코딩할 땐 꼭 URL 변형과 패딩을 챙기세요. JWT 구조 전체는 JWT 구조와 서명 원리 글에서 자세히 다뤘어요.

내가 지키는 규칙과 디버깅 순서

  1. URL, 파일명, 쿠키 값에 넣을 거면 처음부터 Base64URL로 만든다.
  2. 받는 쪽 디코더가 패딩 유무를 둘 다 받아 주는지 확인하고, 안 받으면 길이로 복원한다.
  3. 외부 입력은 엄격한 디코더로 검증한다 — 관대한 디코더는 틀린 값을 돌려줄 수 있다.
  4. 실패한 토큰을 볼 땐 +, /, -, _, 공백이 섞였는지부터 본다.

마지막 항목이 제일 쓸모 있어요. 실패한 토큰에 공백이 섞여 있다면 거의 확실히 + 가 중간에 변한 거예요. Base64 디코더에 붙여 넣고 표준과 URL-safe 모드를 번갈아 디코딩해 보면 어느 규칙으로 만들어졌는지 금방 판별돼요.

자주 묻는 질문

Python에서 binascii.Error: Incorrect padding 은 어떻게 해결하나요?

패딩 = 가 빠진 Base64URL 문자열을 표준 디코더에 넣었을 때 주로 나요. base64.urlsafe_b64decode(s + '=' * (-len(s) % 4)) 처럼 길이를 4의 배수로 맞춰 디코딩하세요. 그래도 나면 문자열이 중간에 잘렸는지 확인하세요.

Base64와 Base64URL을 구분하는 방법이 있나요?

- 나 _ 가 있으면 Base64URL, + 나 / 가 있으면 표준 Base64예요. 둘 다 없으면 어느 쪽으로 디코딩해도 결과가 같아요. 끝에 = 가 없고 길이가 4의 배수가 아니면 패딩을 생략한 Base64URL일 가능성이 커요.

URL에 표준 Base64를 퍼센트 인코딩해서 넣으면 안 되나요?

넣을 수는 있지만 길이가 늘고, 중간 시스템에서 이중 인코딩이나 디코딩 누락이 생기기 쉬워요. 받는 쪽 코드와 프록시, 로그 도구까지 모두 일관되게 처리해야 해서 실수할 여지가 많아요. 처음부터 Base64URL을 쓰는 게 단순하고 안전해요.

패딩을 빼도 원래 데이터를 복원할 수 있나요?

네. Base64 출력 길이는 원래 4의 배수라서, 남은 길이를 4로 나눈 나머지로 빠진 = 개수를 정확히 알 수 있어요. 나머지 2면 두 개, 3이면 한 개를 붙이면 돼요. 나머지가 1이면 데이터가 잘린 거라 복원할 수 없어요.

Java에서 Illegal base64 character 2d 에러는 무엇인가요?

16진수 2d는 - 문자예요. Base64URL로 만든 문자열을 Base64.getDecoder() 로 디코딩해서 생기는 에러라, Base64.getUrlDecoder() 로 바꾸면 해결돼요. 5f(_)도 같은 원인이에요.

정리

표준 Base64와 Base64URL은 + / = 세 글자만 달라요. 그런데 그 세 글자가 URL에서 공백·경로·구분자로 오해되면서, 32바이트 토큰 4개 중 3개꼴로 깨질 위험을 안고 있어요. URL에 들어갈 값은 처음부터 Base64URL로, 디코딩은 패딩을 복원해서, 외부 입력은 엄격한 디코더로. 이 세 가지면 "일부 사용자만 실패" 같은 버그를 미리 막을 수 있어요.

값을 직접 확인할 땐 Base64 인코더/디코더와 URL 인코더를 같이 쓰면 편해요. Base64가 왜 33% 커지는지는 Base64가 쓰이는 곳과 용량 증가 원리에서 볼 수 있어요.

#Base64URL#URL safe Base64#Base64 패딩#Incorrect padding#InvalidCharacterError#Illegal base64 character#JWT 디코딩
← 이전 글HEX RGB HSL 차이와 변환 공식, 상황별 CSS 색상 표기 고르는 법다음 글 →디자인 시스템 컬러 스케일 만들기 — 50~950 단계와 OKLCH 생성 코드