PHP에서의 Base64 디코딩: 완전한 가이드
서포트 티켓, API 로그, 설정 파일, 혹은 URL 한가운데서 마주치게 됩니다. 알파벳과 숫자로 길게 이어진 문자열, 그 사이사이에 드문드문 +나 /가 있고, 끝에는 =가 하나둘 붙어 있는 형태죠. 단번에 알아볼 수 있습니다. Base64는 바이너리를 텍스트로 바꾸는 포맷입니다. 원본 데이터의 바이트 3개를 64글자 알파벳에서 고른 문자 4개로 다시 쓰고, 바이트 수가 3의 배수가 아닐 때는 = 기호 하나둘이 꼬리를 마무리합니다. 디코딩은 이 교환에서 줄어드는 방향입니다. 문자 4개가 들어가면 바이트 3개가 나옵니다. 이 사이트의 홈 페이지가 이 포맷을 한 걸음씩 설명해 두었으니, 이 글은 본래 있어야 할 곳에 에너지를 씁니다. 바로 PHP 쪽의 일입니다.
먼저 헤드라인 뉴스부터. PHP는 PHP 4 시절부터 코어에 Base64 디코더를 싣고 다닙니다. base64_decode()는 확장도, Composer 패키지도, 설정도 필요하지 않고, PHP가 도는 곳이라면 어디든 돌아갑니다. 별로 좋지 않은 소식: 기본 모드인 이 함수의 기질은 손상된 입력을 조용히 삼켜 버리고, 한마디 없이 쓰레기를 건넵니다. 좋은 소식은 점점 좋아집니다. 플래그 하나($strict)로 이 함수를 제대로 된 문지기로 바꿀 수 있고, 어떤 모드를 골라야 하는지, 입력이 진짜임을 어떻게 증명해야 하는지, 바이트를 다시 의미로 바꾸는 법을 알면, Base64는 더 이상 수수께끼 버그의 원천이 아니라 자동화할 수 있는 일상 루틴이 됩니다.
크기에 대해 간단히 한 마디: 디코딩은 데이터를 약 4분의 1로 줄입니다(문자 4개가 들어가면 바이트 3개가 나오므로), 그래서 출력은 언제나 입력보다 적은 메모리를 차지합니다. 디코딩 때문에 메모리가 터진다고 걱정할 일은 결코 없습니다. 이제 이 도구와 만나볼까요.
일을 해내는 함수
완전한 서명은 이렇습니다. 최신 PHP가 보고하는 그대로입니다:
base64_decode(string $string, bool $strict = false): string|false
그 한 줄에 있는 단어 세 개가 모든 일을 합니다. $string에는 크기 제한이 없습니다. 메가바이트 하나는 1밀리초가 채 안 되어 디코딩되니, 파일 하나를 통째로 한 번의 호출로 디코딩하는 것을 막을 것이 아무것도 없습니다. 반환 타입이 전체 계약을 말합니다. 디코딩된 바이트의 문자열, 아니면 false. 예외도, 오류 코드도, 두 번째 채널도 없습니다. false는 당신이 얻을 수 있는 유일한 신호이므로, 그것을 검사하는 것이 일의 일부입니다. 그리고 매뉴얼의 한 문장은 외워둘 가치가 있습니다. 반환되는 데이터는 바이너리일 수 있습니다. 결과에 PNG나 ZIP, 혹은 해시가 들어가는 순간, 그것은 느슨한 의미로든 어떤 의미로든 "텍스트 문자열"이 아니지만, PHP는 그래도 괜찮다며 문자열처럼 다뤄도 좋게 놓아두기만 합니다. 그 유연함은 초능력인 동시에 함정이며, 아래 절들이 그것을 잘 통제합니다.
버전 태그를 빠르게 훑어보겠습니다. 이어받은 코드는 막무가내로 가정하는 버릇이 있으니까요. 이 함수는 PHP 4부터 코어에 있었습니다. $strict 매개변수는 2006년 11월, PHP 5.2.0에서 등장했습니다. PHP 8.0부터 서명에는 진짜 네이티브 타입이 붙었습니다(위에 보이는 string와 bool, 그리고 string|false 반환 타입). 그래서 IDE와 정적 분석기가 드디어 이 함수가 실패할 수 있다는 것을 알게 되었습니다. PHP 8.1부터는 null을 넘기면 비권장 알림이 발생합니다. "아무것도 아니다"라는 뜻이라면, 명시적으로 ''를 쓰세요:
$decoded = base64_decode('');
var_dump($decoded); // string(0) ""
strict 모드, 혹은 무언의 정리
$strict 플래그는 성격이 완전히 다른 두 가지를 오가는 스위치입니다. 꺼진 상태(기본값)에서는 디코더는 다정한 건망귀입니다. Base64 알파벳에 없는 모든 문자가 조용히 버려지고, 나머지는 디코딩되며, 아무도 알리지 않습니다. 매뉴얼은 노골적으로 말합니다. 그 밖의 경우, 잘못된 문자는 조용히 폐기된다는 것입니다. 켜진 상태에서는 디코더가 문지기입니다. 인식을 못 하는 첫 번째 문자가 등장하는 순간, 페이로드 전체가 false를 받게 됩니다.
손해 보고서입니다. 아래 행은 모두 PHP 8.x에서 base64_decode()의 실제 동작입니다:
| 입력 | 허용 (기본값) | strict |
|---|---|---|
Zm9vYmFy, 깨끗함 |
"foobar" |
"foobar" |
Zm9v\r\nYmFy, 문자열 중간에 CRLF |
"foobar" |
"foobar" |
" Zm9vYmFy ", 양끝에 공백 |
"foobar" |
"foobar" |
Zm9v\x0bYmFy, 세로 탭 |
"foobar" |
false |
Zm9v\x00YmFy, 임베디드 NUL 바이트 |
"foobar" |
false |
V@hpcy, 고립된 @ |
쓰레기 3바이트 | false |
Zm9vY, 다섯 글자 |
"foo", 마지막 글자 제거됨 |
false |
Z, 글자 하나 |
"", 빈 문자열 |
false |
=Zm9, 앞쪽에 패딩 |
"fo" |
false |
Zm9vYmFy==, 완결 그룹 뒤에 패딩 |
"foobar" |
false |
Zm9vYmFy==A, 패딩 뒤에 데이터 |
"foobar" |
false |
Zm9vYmF, 일곱 글자, 패딩 없음 |
"fooba" |
"fooba" |
세 행은 두 번째로 살펴볼 가치가 있습니다. V@hpcy 행은 입력이 신뢰할 수 없는 모든 곳에서 허용 모드가 위험한 이유를 보여 줍니다. 고립된 @는 디코딩을 멈추게 하지 않습니다. 그저 사라질 뿐이고, 나오는 바이트 3개는 아무 의미도 없습니다. 글자 하나인 Z 행은 빈 결과가 거의 아무것도 증명하지 못한다는 것을 보여 줍니다. 한 글자 페이로드는 실패 없이 빈 문자열로 "디코딩"됩니다. Zm9vYmFy==A 행은 디코더가 패딩 뒤에 나타나는 데이터를 기꺼이 무시하는 모습을 보여 주는데, 이렇게 해서 잘렸거나 조작된 페이로드가 완벽해 보일 수 있는 것입니다.
strict 모드는 그래도 무엇을 통과시키나요? 정확히 공백 문자 4개입니다. 공백, 탭, 캐리지 리턴, 라인 피드로, 어떤 위치에 있든, = 기호 바로 옆이어도 통과합니다. 의도적인 것입니다. MIME 래핑된 이메일 페이로드는 인코딩된 스트림 안에 CRLF 줄바꿈을 싣고 다니고, strict 모드는 전처리 없이 그것들을 씹어 넘깁니다(왜 그런지는 아래 이메일 절에서 설명합니다). 알파벳 문자가 아닌 나머지는, NUL 바이트부터 세로 탭까지, 모두 false를 맞게 됩니다.
알아둘 만한 진짜 관대함이 하나 더 있습니다. PHP만의 특징은 아닙니다. PHP는 빠진 패딩을 당신 대신 조용히 채워 줍니다. 패딩이 전혀 없는 일곱 글자 페이로드 Zm9vYmF는 정확히 "fooba"로 디코딩됩니다. 패딩이 있는 형제 Zm9vYmF=와 똑같이, 두 모드 다 마찬가지입니다. RFC 4648은 일반적인 경우에 패딩을 요구하므로, 패딩 없는 꼬리를 받아 들이는 것은 의도적인 완화입니다. 그리고 PHP에만 해당하는 것이 아닙니다. Go의 RawStdEncoding과 Java의 디코더도 같은 패딩 없는 입력을 받아들입니다. PHP 쪽과 파트너 시스템이 엣지 케이스 페이로드에서 서로 다른 결과를 낸다면, 빠진 패딩부터 의심해 보세요. 거기서 시작하는 경우가 대부분입니다.
표준은 strict 기질에 동의합니다. RFC 4648 3.3절은, 구현은 알파벳 밖의 문자를 포함하는 인코딩된 데이터를 반드시 거부해야 한다고 말합니다. 다만 둘러싼 명세가 다르게 말하는 경우에는 예외입니다(MIME은 전형적인 "다르게 말해주는" 경우). 같은 절은 그 이유도 설명합니다. 알파벳이 아닌 문자는 은밀한 채널로 악용될 수 있습니다. 디코더가 버리는 문자 속에 정보를 숨기는 방식이죠. 실제로 디코더 버그를 일으키기 위해 사용된 적도 있습니다. 입력이 외부 세계로부터 온다면, strict 모드는 스타일 선택이 아닙니다. 표준이 요구하는 것이 바로 그것입니다.
페이로드가 Base64임을 증명하는 법
조용히 실패할 수 있는 디코더는, 앞에 검증 파이프라인을 두어야 마땅합니다. 세 겹으로, 각 겹이 다른 겹이 놓치는 것을 잡습니다.
첫 번째 겹은 정규 표현식 모양 검사입니다. 알파벳 문자만 허용하고, 맨 끝에 패딩이 최대 두 개까지.
$shapeLooksPlausible = preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $payload) === 1;
정규 표현식은 다른 무엇이 돌기 전에, 눈에 띄는 쓰레기를 먼저 잡습니다(고립된 공백, @ 기호, 문자열 중간에 있는 패딩). 하지만 이것은 검증기가 아닙니다. Zm9vYmFy=가 패딩 하나가 붙은 아홉 글자라는 것을, strict 모드도 거부하는 경우라는 것을 이 정규 표현식은 볼 수 없습니다. 바로 그래서 두 번째 겹이 존재합니다. Base64의 의미론을 이해하는 검사는 strict 디코딩뿐이므로, 마지막 결정은 strict 디코딩이 합니다.
세 번째 겹은 모두가 잊는 부분입니다. false를 명시적으로 처리하세요. 당신이 얻을 수 있는 유일한 신호이니까요.
function decode_payload(string $payload): string
{
$clean = str_replace(["\r", "\n"], '', $payload);
$decoded = base64_decode($clean, true);
if ($decoded === false) {
throw new InvalidArgumentException('Not a valid Base64 payload.');
}
return $decoded;
}
앞에서 하는 str_replace()는 선택적인 안심 장치입니다. strict 모드는 이미 CRLF를 허용하지만, 줄바꿈을 제거해 두면 나중에 할 길이 계산이 깔끔하게 유지됩니다. 깨끗한 페이로드의 문자 수는 언제나 4의 배수니까요. (4의 배수에 하나 더한 수, 예를 들어 5나 9 같은 수는 Base64에서는 불가능하고, strict 모드는 그것을 거부합니다.) 참고로 이 함수는 스스로 예외를 던지지 않습니다. 검사는 당신이 직접 작성해야 하는 것입니다.
URL-safe Base64
현실에서는 두 번째 알파벳과도 만나게 되는데, 이게 바로 물리는 쪽입니다. 표준 Base64는 +와 /를 사용하는데, 이 두 문자는 URL에서는 골칫덩이입니다. 쿼리 문자열의 +는 PHP가 보기 전에 이미 공백으로 해석되고, /는 경로 구분자입니다. RFC 4648 5절은 해결책을 정의합니다. URL과 파일명 안전 알파벳으로, +가 -로, /가 _로 바뀌고, 끝의 = 패딩은 보통 문자 수를 아끼기 위해 생략됩니다. RFC는 이것을 "base64 인코딩과 같은 것으로 간주해서는 안 된다"고 단호히 말하며, 가장 자주 들을 이름은 base64url입니다. JSON Web Token, OAuth state 파라미터, API 세션 ID, 그리고 비디오 사이트 URL이 모두 이 방언에 살고 있습니다.
디코더 쪽은 두 단계입니다. 알파벳을 원래대로 바꾼 뒤, 빠진 패딩을 복원하는 것. 어디서든 다시 재사용하게 될 헬퍼 함수는 이렇습니다:
function base64url_decode(string $data): string|false
{
$standard = strtr($data, '-_', '+/');
$missing = strlen($standard) % 4;
if ($missing !== 0) {
$standard .= str_repeat('=', 4 - $missing);
}
return base64_decode($standard, true);
}
var_dump(base64url_decode('aGk_PnRoZXJl')); // string(9) "hi?>there"
최신 PHP는 여기서 당신 편입니다. 빠진 패딩을 당신 대신 채워 주므로, 명시적인 복원은 이중 안전장치일 뿐입니다(그리고 코드를 더 오래된 PHP 버전에서도 이식 가능하게 유지해 줍니다). 위험의 방향은 한쪽입니다. URL-safe 텍스트를 허용 모드의 표준 디코더에 넣으면, -와 _ 문자는 표준 알파벳에 애초에 없으므로 그냥 버려집니다. 출력이 있어야 할 것보다 짧게 나오는데, 오류도, 알림도, 아무것도 없습니다. strtr() 치환을 먼저 실행해야 하며, 더 좋은 것은 언제나 헬퍼 함수를 거치는 것입니다.
솔직한 주의점 하나. URL-safe 페이로드가 우연히 -도 _도 포함하지 않는다면, 두 알파벳은 그 데이터에 대해 바이트 단위로 완전히 동일하고, 어느 디코더를 썼든 상관이 없습니다. 위험은 그 문자들이 있을 때만 나타납니다. 알파벳이 다른 유일한 곳이기 때문입니다.
텍스트, 바이트, 문자 인코딩
Base64는 당신의 바이트가 무슨 의미인지 모릅니다. PHP의 디코더도 그 맹점을 그대로 물려받았습니다. 이 코덱은 문자 인코딩을 구별하지 않습니다. UTF-8 텍스트든, Windows-1252 텍스트든, JPEG든, 해시든, 들어간 8비트 값을 그대로 돌려줄 뿐입니다. PHP 자체도 같은 입장을 고수합니다. 문자열은 바이트의 시퀀스일 뿐, 그 이상은 아닙니다. 결과를 표시하거나 다른 텍스트와 비교하려 하는 순간, 누군가는 두 가지 질문에 답해야 합니다. 이건 애초에 텍스트인가, 그렇다면 어느 문자 인코딩인가.
실용적인 검사법은 두 개의 바구니로 나뉩니다. 바이너리는 거의 언제나 NUL과 저번지 제어 바이트로 자신을 알리고, 유효하지 않은 UTF-8 텍스트가 두 번째 바구니입니다. mbstring 확장(기본적으로 비활성화되어 있음)이 엄격한 UTF-8 검사를 제공해 줍니다:
function looks_binary(string $bytes): bool
{
if ($bytes === '') {
return false;
}
if (strpbrk($bytes, "\x00\x01\x02\x03\x04") !== false) {
return true;
}
return !mb_check_encoding($bytes, 'UTF-8');
}
var_dump(looks_binary("\x89PNG\r\n\x1a\n...png body")); // bool(true)
var_dump(looks_binary("héllo wörld, 日本語")); // bool(false)
페이로드가 구식 문자 인코딩의 텍스트라면, 그것이 당신의 HTML에 닿기 전에 변환하세요. Windows-1252는 웹과 데스크톱 데이터에서 가장 흔한 구식 인코딩이며, 이 인코딩과 그냥 ISO-8859-1의 차이는 바이트 0x93가 꾸불이 따옴표인지, 보이지 않는 제어 문자인지를 가릅니다:
// Windows-1252의 "café": é는 단일 바이트 0xE9
$legacy = base64_decode('Y2Fm6Q==', true);
$utf8 = mb_convert_encoding($legacy, 'UTF-8', 'Windows-1252');
var_dump($utf8); // string(5) "café": 이제 é는 UTF-8 바이트 2개
유명한 mb_detect_encoding()에 대한 경고입니다. PHP 매뉴얼 자체도 자동 감지는 "전혀 신뢰할 수 없다"고 말하며, 키 없이 메시지를 복호화하는 것에 비유합니다. Windows-1252의 "café"를 넣으면 Windows-1252라고 말할 수도 있고, PNG 헤더를 넣으면 기꺼이 다시 Windows-1252라고 말할 수 있습니다. ISO-8859 계열 문자 인코딩은 가능한 모든 바이트 값에 대해 정의되어 있기 때문에, 뭐와든 일치할 수 있으니까요. 감지는 최후의 수단으로 여기고, 선언된 문자 인코딩(헤더, 설정 줄, 데이터베이스 정렬 규칙)이 있으면 언제나 그것을 믿으세요. 나머지는 기본적으로 UTF-8 또는 바이너리로 처리합니다.
페이로드가 파일일 때
가장 흔한 파일 작업은, 어떤 내보내기 루틴이 했던 일의 반대입니다. .b64 텍스트 파일이 도착하고, 원본 파일을 되찾아야 합니다. strict 디코딩과 false 검사만으로도, 이것은 이미 프로덕션 형태입니다:
$encoded = file_get_contents('/var/www/uploads/blob.b64');
$decoded = base64_decode($encoded, true);
if ($decoded === false) {
http_response_code(400);
exit('That upload is not valid Base64.');
}
PHP 문자열은 그냥 바이트이므로, 이 경로에서는 페이로드가 텍스트 파일이든 ZIP 아카이브든 영상인지 아무도 신경 쓰지 않습니다. 크기 계산도 당신 편입니다. 디코딩된 출력은 인코딩된 입력 길이의 사분의 삼이므로, 디코딩이 메모리를 더 나쁘게 만드는 일은 결코 없습니다.
좋은 습관은, 어떤 라벨을 믿기 전에 바이트가 스스로를 알게 하는 것입니다. finfo 클래스(fileinfo 확장, 표준 PHP 빌드에 번들됨)가 데이터가 실제로 무엇인지 알려 줍니다:
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
var_dump($mime); // string(9) "image/png"
$extensions = ['image/png' => 'png', 'application/pdf' => 'pdf', 'application/zip' => 'zip'];
$ext = $extensions[$mime] ?? 'bin';
$target = '/var/www/uploads/file-' . bin2hex(random_bytes(4)) . '.' . $ext;
file_put_contents($target, $decoded);
마지막 단계가 보이는 것보다 중요합니다. 이미지를 주장하지만 디코딩하면 다른 것이 되는 페이로드, 바로 두 번째 의견이 잡는 종류의 것이죠. 나중에 복원된 파일을 브라우저로 다시 서빙한다면, 보내는 Content-Type은 파일명이 아니라 같은 finfo 검사에서 나와야 합니다.
data URI, 클립보드 포맷
흔히 보는 도착 방식이 하나 있습니다. 누군가가 폼에 이미지를 붙여 넣고, 프론트 엔드가 완전한 data URI를 당신에게 건네는 것: data:image/png;base64,iVBORw0KGgo.... RFC 2397은 모양을 정의합니다. data:, 옵션 미디어 타입, 옵션 ;base64 플래그, 쉼표, 그리고 그 뒤에 데이터. 플래그가 있으면 페이로드는 Base64이고, 없으면 페이로드는 퍼센트 인코딩된 일반 텍스트입니다. 더 드물지만 합법적이죠. 미디어 타입이 생략되면 기본값은 text/plain;charset=US-ASCII입니다. 여기서 애초에 왜 Base64인가? URI는 원시 바이트나 쉼표를 안전하게 포함할 수 없는데, Base64는 이스케이프가 전혀 필요 없는 알파벳을 하나 제공하기 때문입니다.
function split_data_uri(string $uri): ?array
{
if (!str_starts_with($uri, 'data:') || !str_contains($uri, ',')) {
return null;
}
$meta = substr($uri, 5, strpos($uri, ',') - 5);
$payload = substr($uri, strpos($uri, ',') + 1);
$isBase64 = str_ends_with($meta, ';base64');
$mime = $isBase64 ? substr($meta, 0, -7) : $meta;
if ($mime === '') {
$mime = 'text/plain;charset=US-ASCII';
}
return [$mime, $isBase64, $payload];
}
$uri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
[$mime, $isBase64, $payload] = split_data_uri($uri);
var_dump($mime); // string(9) "image/png"
이 포맷 안에는 함정이 두 개 숨어 있습니다. 첫 번째는 빠진 ;base64 플래그입니다. 플래그 없는 합법적인 data URI는 퍼센트 인코딩된 페이로드를 싣고 다니고, 그것을 base64_decode()에 돌리면 쓰레기가 나옵니다. 두 번째는 주장하는 미디어 타입입니다. 그것은 송신자의 힌트이지, 사실이 아닙니다. 파일 절의 finfo 검사가 바로 당신의 사실입니다. 그리고 RFC 자체의 조언도 기억해 두세요. data URI는 짧은 값에만 유용하다는 것입니다. URL 안에 수 MB짜리 이미지가 들어 있으면, 이것은 패턴이 아니라 악취입니다.
JWT: 들여다볼 수 있는 토큰
웹에서 가장 유명한 Base64 페이로드는 JSON Web Token입니다. 그리고 모양을 알면 가장 덜 무서운 페이로드이기도 하죠. RFC 7519에 따르면, 축약형 JWT는 점으로 구분된 URL-safe Base64 세 부분으로 이루어져 있습니다. 헤더, 페이로드, 서명이며, 각각 패딩 없이, 줄바꿈 없이 인코딩됩니다(RFC 7515는 추가 문자가 슬쩍 들어올 수 없다고 명시합니다). 헤더와 페이로드는 그냥 JSON입니다. 그래서 누구나 읽을 수 있고, 그래서 토큰에 손을 대기 전에 다음 단락을 모두 이해해야 합니다.
위 헬퍼로 앞의 두 부분을 읽는 것은 5줄짜리 일이며, 토큰의 수수께끼를 푸는 좋은 방법입니다:
$token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.8GljXWCrvkTYln_WtTVhyWSzflOC1iGL8jBDUHQmaEE';
[$headerPart, $payloadPart] = explode('.', $token);
$header = json_decode(base64url_decode($headerPart), true);
$payload = json_decode(base64url_decode($payloadPart), true);
var_dump($header);
// array(2) { ["alg"] => string(5) "HS256" ["typ"] => string(3) "JWT" }
var_dump($payload);
// array(3) { ["sub"] => string(10) "1234567890" ["name"] => string(8) "John Doe" ["iat"] => int(1516239022) }
자, 이제 중요한 부분입니다. 세 번째 부분은 서명이고, 방금 디코딩한 두 부분은 비밀도, 인증된 것도 아닙니다. 패킷 캡처를 가진 사람은 누구나 그것들을 읽을 수 있고, 텍스트 에디터를 가진 사람은 누구나 그것들을 다시 쓸 수 있습니다. 서명을 검증하기 전에 페이로드를 믿는 것은 전설적인 JWT 버그입니다. 프로덕션에서는 그 검사를 손으로 만들지 마세요. 커뮤니티의 답은 firebase/php-jwt 패키지로, 현재 v7이며, RFC 7519에 부합하고 PHP 8.0 이상을 요구합니다. Composer로 설치하세요:
composer require firebase/php-jwt
그리고 이 API는 먼저 검증한 뒤, 서명이 확인될 때만 페이로드를 건넵니다:
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$secret = 'correct-horse-battery-staple-long-enough-secret';
try {
$claims = JWT::decode($token, new Key($secret, 'HS256'));
var_dump($claims->sub); // 프로퍼티이며, 서명이 확인된 뒤에야 얻을 수 있는 것
} catch (UnexpectedValueException $e) {
// 잘못된 형식의 토큰, 잘못된 서명, 또는 만료된 claims
}
버전에 대한 메모 하나: 이 라이브러리의 v7은 HMAC 알고리즘에 대해 최소 키 길이를 적용하므로, 32바이트보다 짧은 HS256 시크릿은 서명이 검증되기 전에 DomainException으로 거부됩니다. 시크릿은 길게 유지하세요. 라이브러리가 그것을 잊게 내버려 두지 않으니까요.
그 API의 순서에 주목하세요. JWT::decode()는 쓰레기를 돌려주는 대신, 잘못된 서명, 만료된 토큰, 누락된 알고리즘에서 예외를 던지므로, 당신이 받아치는 페이로드는 믿을 수 있는 것입니다. 위 손으로 만든 버전은 이해를 위한 것이고, 당신을 위해 의도되지 않은 토큰을 엿보기 위한 것입니다. 라이브러리는 믿기 위한 것입니다.
HTTP Basic 인증, 가장 오래된 헤더
웹에서 가장 오래된 인증 헤더는 여전히 Base64를 타고 다닙니다. RFC 7617에 따르면, HTTP Basic 요청은 Authorization: Basic 뒤에 username:password의 Base64 인코딩을 보내는 것입니다. RFC는 이것이 인코딩이지 보호가 아니라고 명시합니다. 패킷 캡처를 가진 사람은 한 키 스트로크로 두 부분 모두를 디코딩할 수 있으니까요. 디코딩 쪽에서 당신의 일은 헤더를 파싱하고, strict하게 디코딩한 뒤, 타이밍 공격에 안전한 함수로 비교하는 것입니다.
function basic_credentials(string $header): ?array
{
if (!str_starts_with($header, 'Basic ')) {
return null;
}
$decoded = base64_decode(substr($header, 6), true);
if ($decoded === false || !str_contains($decoded, ':')) {
return null;
}
[$user, $password] = explode(':', $decoded, 2);
return [$user, $password];
}
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$creds = basic_credentials($header);
if ($creds !== null
&& hash_equals('alice', $creds[0])
&& hash_equals('secret123', $creds[1])
) {
// 인증됨
}
두 가지 세부 사항이 이것을 안전하게 유지합니다. explode()의 2라는 제한은 중요합니다. 비밀번호가 합법적으로 콜론을 포함할 수 있기 때문입니다. 그리고 비교는 hash_equals()여야 하며, 절대 ==가 아니어야 합니다. 그래야 공격자가 타이밍을 재며 당신의 사용자 목록을 헤집어 나갈 수 없으니까요. 그리고 이것을 HTTPS로만 서빙하세요. 평문 연결에서는 Base64 레이어는 그저 겉치레일 뿐입니다.
이메일, 모든 것의 시작
Base64는 구체적인 문제를 위해 태어났습니다. 메일 전송은 7비트 ASCII만 실어 나를 수 있었지만, 사람들은 바이너리를 보내고 싶었기 때문입니다. MIME 표준(RFC 2045 6.8절)은 Base64를 바이너리 전송 인코딩 중 하나로 만들었고, 내부 규칙 두 가지를 추가했습니다. 첫째, 인코딩된 줄은 76자리를 넘을 수 없습니다. 둘째, 디코딩 소프트웨어는 줄바꿈을 포함해 알파벳 밖의 모든 문자를 무시해야 합니다. 바로 그 두 번째 규칙 때문에, PHP의 디코더는 어떤 모드에서든 당신이 전처리를 하지 않아도 CRLF 래핑된 페이로드를 씹어 넘깁니다. (위에 strict 모드 표에서 본 \r\n 허용의 기원도 바로 여기입니다.)
$png = "\x89PNG\r\n\x1a\n" . random_bytes(256);
$wrapped = chunk_split(base64_encode($png), 76, "\r\n");
// 나중에 수신쪽에서는 정리 없이 바로:
$decoded = base64_decode($wrapped, true);
var_dump($decoded === $png); // bool(true): 모든 바이트가 왕복을 마쳤다
실용적인 메모 두 가지. 첫째, 래핑은 무게를 더합니다. 76자마다 CRLF가 붙으면, 100 KB 첨부파일은 약 137 KB의 텍스트로 도착합니다(익숙한 4/3 배수 요인, 그리고 줄바꿈 오버헤드를 더한 것). 둘째, 헤더와 여러 부분, 그리고 quoted-printable 형제들이 있는 현실의 이메일에서는, 옵션인 mailparse 확장이 RFC 822 메시지 전체를 부분 부분 해부해 줍니다. 알려진 첨부파일 하나가 목적이라면, strict 디코딩만으로도 충분합니다.
PEM 아머: 키와 인증서
인증서와 키는 PEM 아머를 타고 다닙니다. BEGIN 라벨, 64자 한 줄로 된 Base64 블록, END 라벨. 64자 줄 길이는 원래의 Privacy Enhanced Mail 명세(RFC 1421)에서 이어받은 관례이고, OpenSSL 도구들이 그것을 기대하므로, 다시 아머를 걸 때 중요합니다. 디코딩할 때는 전혀 중요하지 않습니다. 디코더는 줄바꿈을 그냥 무시하기 때문입니다.
$pem = file_get_contents('/etc/ssl/my-key.pem');
preg_match('/-----BEGIN ([A-Z ]+)-----\s*(.*?)\s*-----END \1-----/s', $pem, $m);
$label = $m[1];
$der = base64_decode(preg_replace('/\s+/', '', $m[2]), true);
if ($der === false) {
// 결국 Base64가 아니었다
}
var_dump($label); // string(11) "PRIVATE KEY"
디코딩된 바이트는 DER입니다. 압축된 바이너리 직렬화이며, openssl_* 함수들이 궁극적으로 다루는 것이 바로 이것입니다. 정규 표현식 안의 백레퍼런스 \1가 조용한 영웅입니다. END 라벨이 BEGIN 라벨과 일치함을 보장하므로, 파일에 여러 블록이 있을 때 인증서의 END를 키의 BEGIN에 꿰매는 실수를 피할 수 있습니다.
스트림과 큰 페이로드
디코딩은 당신을 도우는 방향입니다. 출력이 입력 크기의 사분의 삼이므로, Base64 때문에 생기는 메모리 압박은 드뭅니다. 그래도 수백 메가바이트짜리 .b64 파일이 디스크에 내려앉으면, 메모리 점유를 평평하게 유지할 도구가 두 개 있습니다.
첫 번째는 청크 디코딩입니다. 정돈된 입력을 4자 배수인 길이의 조각들로 나누고, 각 조각을 strict하게 디코딩한 뒤, 이어 붙입니다. 각 청크는 스스로 완전한 유효 페이로드이므로 경계에서 아무것도 잃지 않고, 손상된 파일은 보고할 수 있는 오프셋과 함께 빠르게 실패합니다.
$clean = str_replace(["\r", "\n"], '', file_get_contents('/var/www/uploads/huge.b64'));
$decoded = '';
$chunkSize = 4 * 50000; // 4자 배수, 호출마다 약 150 KB 출력
for ($offset = 0; $offset < strlen($clean); $offset += $chunkSize) {
$part = base64_decode(substr($clean, $offset, $chunkSize), true);
if ($part === false) {
exit('Corrupted payload near offset ' . $offset);
}
$decoded .= $part;
}
최신 하드웨어에서 메가바이트짜리 Base64는 1밀리초가 채 안 되어 디코딩되므로, 이 루프는 거의 비용이 들지 않습니다. 속도가 아니라 검증과 보고의 특성 때문에 이것을 고르세요.
두 번째 도구는 스트림 세계의 시민입니다. convert.base64-decode 스트림 필터입니다. 모든 PHP 스트림에서 작동하므로, 파일 포인터, php://input, 또는 메모리 스트림에서 바로 디코딩할 수 있고, 인코딩된 텍스트 전체를 한 변수에 담아 두지 않아도 됩니다. 허용 함수처럼, Base64 알파벳 밖의 모든 문자를 그냥 건너뜁니다:
$in = fopen('/var/www/uploads/huge.b64', 'rb');
$out = fopen('/var/www/uploads/huge.bin', 'wb');
stream_filter_append($in, 'convert.base64-decode', STREAM_FILTER_READ);
stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);
어느 도구를 고르나요? 데이터가 스트림을 지나가고 배관 공사는 PHP에게 맡기고 싶다면 필터를. 청크 단위 검증, 진행 상황 보고, 손상 위치 오프셋이 필요하다면 청크 루프를.
데이터베이스, 설정 파일, 환경 변수
Base64는 텍스트 컨테이너입니다. 그래서 예상하지 못한 곳에서 모습을 드러냅니다. 데이터베이스에서는 바이너리 블롭(파일, 아이콘, 직렬화된 구조)이 Base64로 TEXT 칼럼에 살 수 있습니다. 텍스트를 전제로 하는 모든 도구에서도 살아남으면서요. 저장되는 값이 원본보다 약 33퍼센트 커질 것을 예상하고, 칼럼 크기를 그에 맞게 정하세요. 설정 파일과 환경 변수에서는, Base64는 포맷을 깨뜨릴 값들을 몰래 실어 나르는 트릭입니다. 세미콜론이 있는 데이터베이스 DSN, 따옴표가 있는 비밀번호, 줄바꿈이 있는 값.
// .env 또는 설정 파일, 운영 담당자가 작성한 것:
// DB_DSN_B64 = cGc6aG9zdD1kYjtwYXNzd29yZD1xdSJvdGU=
$dsn = base64_decode(getenv('DB_DSN_B64') ?: '', true);
if ($dsn === false) {
exit('DB_DSN_B64 is not valid Base64.');
}
// 이제 $dsn은: pg:host=db;password=qu"ote
같은 주의가 여기서 두 번 적용됩니다. 첫째, 이것은 포맷 안전이지, 비밀이 아닙니다. 개발자가 설정 파일을 읽는 순간, 한 번의 호출로 값을 디코딩할 수 있습니다. 시크릿을 Base64로 저장해 놓고 이를 암호화되었다고 불러서는 안 됩니다. 둘째, 부팅 시점에 검증하세요. 손상되거나 반쪽짜리로 붙여 넣힌 환경 변수 값은 strict 호출로부터 오는 false이며, 한 줄짜리 검사가 수수께끼 같은 런타임 오류를 행동할 수 있는 시작 메시지로 바꿔 줍니다.
커맨드 라인에서
모든 디코딩이 웹 요청 안에서 일어나지는 않습니다. CLI 스크립트, cron 잡, 한 줄 명령은 언제나 Base64를 디코딩하며, 커맨드 라인이 바로 이 함수가 php://stdin을 만나는 곳입니다:
php -r 'fwrite(STDOUT, base64_decode(file_get_contents("php://stdin"), true));' < payload.b64 > restored.bin
셸은 이미 자신만의 Base64 유틸리티(coreutils의 base64 -d)를 가지고 있으며, 빠른 작업에는 충분합니다. PHP 한 줄 명령은 다음 단계가 PHP 로직일 때를 위한 것입니다. 데이터베이스에 쓰는 것, API를 호출하는 것, 검증을 실행하는 것. 셸 특유의 함정이 두 개 있습니다. 디코딩의 출력은 원시 바이트이므로, 그것을 망가뜨릴 터미널이 아니라 파일이나 바이트를 아는 명령에 보내세요. 그리고 한 줄 명령 안에서도 strict 플래그는 켜 두세요. 터미널에서 잘린 붙여넣기는 쓰레기 3바이트가 아니라 false를 받을 자격이 있으니까요.
PHP 특유의 함정
PHP에 고유한 함정들을 한곳에 모아 빠르게 둘러보겠습니다:
- 허용 모드 기본값이 가장 큰 함정입니다.
base64_decode('V@hpcy')는 경고 없이 쓰레기 3바이트를 돌려 주므로, 신뢰할 수 없는 입력을 디코딩하는 코드마다 strict 플래그와false검사가 필요합니다. - 허용 모드에서는 글자 하나가 빈 문자열로 디코딩되고, 공백만 있는 문자열도 마찬가집니다. 빈 결과는 거의 아무것도 증명하지 못합니다. 실패를 뜻하는 것은
false뿐이며, 그것은 strict 모드에서만 얻습니다. - 쿼리 문자열의
+는 PHP가 보기 전에 이미 공백입니다. 클라이언트가 퍼센트 인코딩 없이?token=abc+def를 보내면, PHP는abc def를 건넵니다(폼 인코딩 동작이며,parse_str()와urldecode()가 공유하는 것입니다). 디코딩 마법을 아무리 쓰려 해도 그 플러스는 돌아오지 않습니다. URL 안에 넣을 토큰의 해법은 URL-safe Base64(완전히 플러스 없음)입니다. - 빠진 패딩은 당신을 대신해 조용히 채워집니다. 일곱 글자가 여덟 글자처럼 디코딩됩니다. 편하지만, 패딩 하나가 잘린 페이로드도 아무 불평 없이 디코딩된다는 뜻입니다. 그래서 깨끗한 디코딩이 페이로드가 온전히 도착했음을 결코 완전히 증명하지는 못합니다(Go의 패딩 없는 인코더와 Java도 똑같이 관대합니다).
mbstring.func_overload의 유령. 오랫동안 비권장되어 온 설정이, 문자를 세도록strlen()과 친척들을 다시 쓰고 있었으며(PHP 8.0에서 제거됨), 이것이 UTF-8 문자열에서 Base64 바이트 계산을 깨뜨리곤 했습니다. 이어받은 레거시 코드에는 아직 그것을 위한 주석과 우회법이 붙어 있을 수 있습니다. 지워 버리세요.- 디코딩된 바이트는 UTF-8 문자열이 아닙니다. 디코딩된 바이너리에서
preg_match()를/u플래그를 붙여 돌리거나mb_substr()를 쓰면, "잘못된 형식의 입력" 오류의 즉석 원천이 됩니다. 먼저 냄새를 보고, 그때 판단하세요. null을 넘기는 것은 PHP 8.1부터 비권장입니다. 변수가 null일 수 있다면, 호출 전에''으로 병합 처리하세요.$_GET과 친척들은 URL 규칙이 아니라 폼 규칙으로 디코딩됩니다. 값이 퍼센트 인코딩으로 도착했다면,rawurldecode()가 더 안전한 역연산입니다.+를 건드리지 않으니까요.
base64_decode의 짧은 역사
Base64 자체는 모던 웹의 대부분보다 오래되었습니다(이를 관할하는 표준 RFC 4648은 2006년 것이며, 1996년의 MIME 인코딩을 법제화했는데, 그것은 다시 1990년대 초의 PEM 아머에서 내려온 것입니다). PHP의 이야기는 그 자체로 작은 변경 로그입니다.
PHP 4는 base64_decode()를 옵션 없이, strict 모드 없이 코어 함수로 싣고 나왔습니다. 허용 기질이 유일한 기질이었고, 디코더에게 불평해 달라고 요청할 방법도 없었습니다. 2006년 11월의 PHP 5.2.0은 $strict 플래그를 추가했고, 변경 로그 항목은 읽어 볼 가치가 있습니다. 오늘의 RFC 4648의 전신인 RFC 3548 준수를 적용하기 위해 추가된 것이었으니까요. 그 플래그 하나로, 이 함수의 생애에서 가장 유용한 추가가 만들어졌습니다.
그다음은 디버깅의 시대였습니다. PHP 5.3은 두 번의 점 릴리스에 걸쳐 strict 모드 버그 연쇄를 고쳤습니다. bug #52327(strict 모드에서 앞자리 패딩이 제대로 처리되지 않음, 5.3.4에서 수정)과 bug #55273(strict 모드에서 패딩 뒤의 공백 문자가 거부됨, 5.3.9에서 수정). (2016년의 정수 오버플로우 수정도 이 함수 이름 아래에 등록되어 있습니다. bug #72836, 공식 제목은 "base64_decode의 정수 오버플로우가 힙 손상을 일으킨다"이고 5.6.25에서 수정되었는데, 버그 리포트의 재현 코드와 패치된 함수가 보여주는 바로는 실제 오버플로우는 디코더가 아니라 base64_encode()의 길이 계산에 있었고, 제목은 원래 리포트에서 이어받은 잘못된 이름입니다.) 각 수정은 위 표에서 보는 동작을 더 조였습니다. PHP 8.0은 두 Base64 함수 모두에 네이티브 매개변수와 반환 타입을 주었습니다. 이 글 위에서 본 서명입니다. 같은 릴리스 라인에서 mbstring.func_overload도 제거했는데, 몇 년간 조용히 바이트 계산을 깨뜨려 온 설정이었죠. PHP 8.1은 이 함수들에 null을 넘기는 것을 비권장 처리했습니다. 그 뒤로, 표면은 동결되었습니다. 매개변수 하나, 플래그 하나, 반환 타입 하나, 변함없이.
덕후를 위한 즐거움 몇 가지
장문 레퍼런스인 만큼, 그저 재미있는 PHP 특정 사실 몇 가지를 나열해 봅니다:
- 빈 항등식.
base64_encode('')와base64_decode('')둘 다''입니다. 이 함수들은 양쪽 방향에서 빈 값을 1급 값으로 다루며,false는 개입하지 않습니다. - 이상한 주소. PHP 매뉴얼에서, 두 Base64 함수는 모두 "기타 기본 확장" 책의 "URLs" 장에 살고 있습니다. 전용 "인코딩" 장은 없는데, 바로 그곳에서 그들을 찾을 수 있습니다. 그 장 목록의 맨 위에,
parse_url()과 친척들보다 앞쪽에요. - 디코더는 동형사상입니다. 고전적인 php.net 사용자 노트 하나는, 이 함수가 모듈로-4 분할 문자열과 모듈로-3 분할 문자열 사이의 동형사상이라고 관찰합니다. 4의 배수로 한 분할은 유효한 분할이라는 것을 형식적으로 말한 것이지요. 청크 디코딩 절이 애초에 작동하는 이유이고, 1 MB 파일을 50 KB 조각으로 나눠도 손실 없이 디코딩할 수 있는 이유입니다.
- 매개변수 하나, 플래그 하나. 20여 년 동안,
base64_decode()는 정확히 하나의 매개변수($strict)를 얻었고,base64_encode()는 아무것도 얻지 않았습니다. - 더 오래된 형제들이 있습니다. 같은 코어 확장은
convert_uuencode()와convert_uudecode()도 싣고 다니며(매뉴얼에서는 String Functions 장에 등재되어 있음), dial-up 시대의 유물이죠. 그때 uuencode가 바이너리 전송의 선택이었으니까요. 거의 필요로 하지 않을 테지만, 고대.uu파일이 언젠가 당신의 도착함에 떨어지면, PHP가 그것을 열 수 있습니다. - strict 모드는 이메일을 위해 열린 문을 유지합니다. 공백 문자 4개(공백, 탭, 캐리지 리턴, 라인 피드)는 의도적으로 strict 모드를 항해해 지나가므로, MIME 래핑된 첨부파일은 전처리를 필요로 하지 않습니다. NUL 바이트를 포함해 나머지는 모두
false입니다.
반대 방향
이것이 디코더 쪽입니다. 그리고 대부분의 고통이 사는 곳이기도 합니다. 디코딩은 다른 사람들의 데이터와 만나는 곳이니까요. 그들의 패딩 선택, 그들의 줄바꿈, 그들의 문자 인코딩, 그들의 토큰. 반대 방향, base64_encode()로 바이트를 Base64 문자열로 바꾸는 일은 더 온순한 동물입니다. 실패하지 않고, strict 모드가 없으며, 그것만의 함정 묶음(이중 인코딩, 래핑 불일치, 크기 청구서)은 자기만의 가이드를 가집니다. 이 페이지에서 링크된 PHP에서의 Base64 인코딩은 인코더를 같은 깊이로 다룹니다.
마지막 업데이트: 2026-09-08
관련 문서: PHP에서의 Base64 인코딩: 완전한 가이드