바로 확인할 수 있는 한 문장부터 잡으세요. 개발자 도구 Network를 열고, 주소창 전체와 문서 요청의 요청 줄을 대조하세요. 주소창에는 # 뒤 키가 있어도 됩니다. 요청 줄에는 같은 구간이 나오면 안 됩니다. 나왔다면 「브라우저 기본 동작」이 아니라, 구현이 프래그먼트를 쿼리에 썼거나 스크립트가 읽은 뒤 요청에 넣었다는 뜻입니다.
물음표와 해시는 같은 보호가 아닙니다
일회용 암호문 링크는 자주 「키가 URL 안에 있다」고 설명됩니다. 그 문장은 너무 거칠어서, 전혀 다른 두 사실을 한 덩어리로 묶습니다. URL은 쿼리와 프래그먼트를 동시에 가질 수 있습니다. 물음표 ?가 쿼리를 열고, 해시 #가 프래그먼트를 엽니다. 둘 다 주소창에 보이며, 받는 사람은 보통 문자열 전체를 복사합니다. HTTP가 둘을 다루는 방식은 다릅니다.
쿼리는 요청 대상의 일부가 됩니다. s.html?id=abc123을 열면 서버, 리버스 프록시, CDN, 액세스 로그는 설계상 id=abc123을 봅니다. 프래그먼트는 이번 문서 요청에 기본으로 들어가지 않습니다. s.html?id=abc123#키를 열면 브라우저는 id가 붙은 그 페이지를 서버에 요청하고, 해시 뒤는 현재 탭에 남겨 페이지 스크립트가 location.hash로 읽게 합니다.
따라서 「키가 URL 안에 있다」는 말은, 사람이 전체 문자열을 볼 수 있는지만 답합니다. 서버가 키를 볼 수 있는지는 답하지 않습니다. 키를 두기에 맞는 구간은 해시 뒤입니다. 전제는 구현이 정말로 그 자리에 두고, 편하려고 쿼리에 넣지 않았다는 점입니다.
규격이 말하는 것: 프래그먼트는 클라이언트에 남습니다
특정 제품의 내부 약속이 아닙니다. RFC 3986 3.5절은 프래그먼트를 「2차 자원」 식별자로 정의합니다. 해시가 나타난 뒤부터 URI 끝까지입니다. 그 구간을 어떻게 해석할지는 받아 온 문서 종류에 따르며, 클라이언트가 처리합니다. URI 스킴이 다시 쓰지 않습니다.
RFC 9110 7.1절은 이 규칙을 HTTP에 붙입니다. 브라우저가 파싱한 대상 URI에는 프래그먼트가 없습니다. 프래그먼트 식별자는 클라이언트가 처리하기 때문입니다. 문서 요청을 보낼 때 요청 줄에는 해시와 그 뒤가 나오면 안 됩니다.
Referer도 같은 칼질을 합니다. RFC 9110은 사용자 에이전트가 Referer를 만들 때 프래그먼트와 userinfo를 넣지 말 것이라고 적습니다. MDN의 Referer 설명도 같습니다. 헤더에는 출처, 경로, 쿼리가 있을 수 있고, # 프래그먼트는 없습니다. W3C Referrer Policy는 URL을 referrer로 줄이는 단계에서 먼저 프래그먼트를 비운 뒤, 정책에 따라 경로와 쿼리를 남길지 결정합니다.
세 자료를 합치면 증명되는 것은 한 가지입니다. 규격을 따른 브라우저는 해시 뒤 키를 HTTP 요청 줄이나 Referer에 쓰지 않습니다. 페이지 스크립트, 확장 프로그램, 또는 여러분이 다른 곳에 붙인 텍스트가 그 구간을 알아서 지운다는 뜻은 아닙니다.
키를 물음표에 넣으면 로그에 평문으로 남습니다
키를 ?id=abc123&key=...로 쓰면 구현은 더 쉽습니다. 서버가 같은 쿼리로 암호문을 꺼내고 대신 복호화할 수 있습니다. 「한 번에 끝내고 싶은 온라인 암호화 페이지」에서는 흔합니다. 「호스팅 측이 키를 보면 안 된다」와는 정반대입니다.
쿼리는 요청 줄에 나타나고, 대부분의 액세스 로그, 리버스 프록시 로그, 일부 CDN 리포트에도 들어갑니다. 키가 쿼리에 들어가는 순간, 「서버는 암호문만 둔다」는 말은 로그 층에서 이미 성립하지 않습니다. 운영자가 그날의 access log를 열면 복호화에 필요한 나머지 절반이 보입니다.
키를 POST 본문으로 옮기면 된다고 말하는 사람도 있습니다. URL 로그는 피할 수 있습니다. 그래도 키는 브라우저를 떠나, 「영지식」이라고 부른 그 기계에 도착합니다. 프래그먼트의 가치는 정반대입니다. 키를 HTTP 필드로 보낼 필요가 없습니다. 페이지가 이 기기에서 location.hash만 읽으면 됩니다.
| 어디에 두나 | 사람이 보나 | 이번 HTTP가 보나 |
|---|---|---|
| 물음표 뒤 쿼리 | 본다. 주소창과 복사한 텍스트에 있다 | 본다. 요청 줄, 프록시, 액세스 로그가 기록한다 |
| 해시 뒤 프래그먼트 | 본다. 주소창과 복사한 텍스트에 있다 | 기본으로는 못 본다. 요청 줄과 Referer는 규격상 이 구간이 없다 |
| POST 필드 | 주소창에는 없다 | 본다. 요청 본문이 서버에 도착한다 |
| 이 기기 메모리에만, URL에 쓰지 않음 | 상대는 못 본다. 다른 안전한 통로가 필요하다 | 못 본다. 다만 「열자마자 쓰는」 일회용 링크로는 넘길 수 없다 |
올바른 분리: 위치는 물음표, 키는 해시
일회용 암호문 링크는 한 번에 두 일을 해야 합니다. 서버에는 「어느 암호문을 꺼낼지」를 알려야 하고, 수신자 브라우저에는 「어느 키로 이 기기에서 복호화할지」를 알려야 합니다. 두 일을 같은 HTTP 필드에 실으면 안 됩니다.
확인할 수 있는 분리는 s.html?id={id}#{key}입니다. 물음표에는 위치용 id만, 해시에는 키만 둡니다. 만들 때 브라우저는 Web Crypto로 AES-256-GCM을 돌리고, 밖으로 나갈 수 있는 것은 암호문뿐입니다. 읽을 때 스크립트는 location.hash를 읽고, 서버에 암호문을 요청한 뒤 이 기기에서 복호화합니다. 서버는 설계상 암호문만 잠시 두고, 읽은 뒤 지웁니다.
MakePwd의 일회용 링크는 이 경계로 구현되어 있습니다. 만들기 페이지와 읽기 페이지 모두 열자마자 쓰며, 계정이 없습니다. 읽기 페이지는 수신자에게 공개이며 로그인을 요구하지 않습니다. 그래도 구현 선택일 뿐, 「해시가 곧 암호화 프로토콜」은 아닙니다. 프래그먼트 자체는 기밀성을 주지 않습니다. 키가 HTTP 필드로 탭을 떠나지 않게 할 뿐입니다.
실제 암호, 주민등록번호, 마스킹하지 않은 표로 실험하지 마세요. 버려도 되는 테스트 문장과, 이번 확인을 위해 만든 일회용 링크를 쓰세요. 대조하는 것은 요청 줄과 주소창이지, 비밀을 한 번 더 퍼뜨리는 일이 아닙니다.
Network와 주소창에서 바로 확인하기
앞 글은 카나리아로 「평문이 업무 데이터로 이 기기를 떠났는지」를 보는 법이었습니다. 이 글은 범위를 좁힙니다. 키가 HTTP에 들어갔는지만 봅니다. 단계는 따로 끝낼 수 있습니다. 앞 글을 먼저 읽지 않아도 됩니다.
해가 없는 테스트 문장을 하나 만들고, 생성된 전체 링크를 복사하세요. 해시가 어디인지 보세요. 앞은 s.html?id=...이고, 뒤가 키입니다. 깨끗한 탭을 열고 개발자 도구에서 Preserve log를 켠 뒤, 붙여넣어 엽니다.
- 주소창과 문서 요청의 요청 줄을 대조하세요. 주소창에는
#와 그 뒤가 남아 있어야 합니다. 문서 요청 URL에는 path와?id=만 보여야 하며, 해시 뒤 키가 나오면 안 됩니다. - 이어지는 XHR / Fetch를 보세요. 암호문을 받는 인터페이스는 설계상
id를 실을 수 있습니다. 요청 본문이나 쿼리에 같은 키 구간이 다시 나오면 안 됩니다. 만들기 인터페이스의 body는 암호문이어야 하며, 방금 입력한 테스트 문장이면 안 됩니다. - 분석 전송의 query와 body는 따로 여세요. 페이지 경로는 나와도 됩니다.
location.href전체가 쓰이면, 키는 「HTTP 기본 동작으로 안 나감」에서 「스크립트가 직접 보고」로 바뀝니다. 구현 문제이며, 규격이 깨진 것이 아닙니다.
세 곳이 모두 깨끗해도 지지할 수 있는 결론은 좁습니다. 지금 쓰는 이 브라우저, 이번 열기에서, 관찰한 HTTP 필드로 키가 탭을 떠나지 않았다는 것입니다. 브라우저, 버전, 페이지 스크립트가 바뀌면 다시 해야 합니다.
이 층 보호가 깨지는 순간
프래그먼트가 막는 것은 「이번 HTTP가 키를 호스팅 측에 넘긴다」입니다. 아래 장면에서는 키가 원래 HTTP 기본 동작에 기대지 않습니다. 규격이 도와주지 않습니다.
첫째, 전체 링크를 채팅, 메일, 티켓에 붙입니다. 수신 측은 사람 눈에 보이는 문자열 전체를 보고, 해시 뒤까지 그 시스템 기록에 남습니다. 어떤 클라이언트는 해시를 버리고 물음표 앞만 미리보기로 보여 줍니다. 그때 수신자가 열면 키가 없고, 읽기 페이지는 키가 없다고 알려야 하며 서버에 다른 키를 다시 요청하면 안 됩니다. 어느 쪽이든, 「가진 사람이 바로 쓰는」 자격 증명을 다른 시스템에 넘긴 것입니다.
둘째, 페이지 스크립트와 확장 프로그램은 location.hash를 읽을 수 있습니다. 읽기 페이지가 동작하는 이유이며, XSS나 악의적 확장이 키를 가져갈 수 있는 이유이기도 합니다. RFC 9110 17.11절은 이미 말해 둡니다. 프래그먼트는 요청에 들어가지 않지만, 사용자 에이전트, 확장, 응답과 함께 온 스크립트에는 보입니다. 리다이렉트가 원래 URL의 프래그먼트를 이어받으면, 이 사이트의 프래그먼트가 다른 출처로 넘어갈 수도 있습니다.
셋째, 브라우저 기록, 화면 공유, 클립보드입니다. 주소창 전체는 로컬 기록에 남습니다. 탭을 회의실에 띄우면 해시 뒤도 화면에 있습니다. 서버를 거치지 않으며, 「프래그먼트는 HTTP로 안 간다」로 반박할 수 없습니다.
넷째, URL을 다시 쓰는 중간 페이지나 단축 링크입니다. 중간 페이지가 path와 query만 넘기면, 수신자가 열 때 해시는 이미 없습니다. 자기 페이지에서 JavaScript로 전체 href를 읽은 뒤 이동하면, 키는 중간 측 프론트엔드에 들어간 것입니다. 단축 링크는 그 자리에서 보세요. 상대에게 건넨 그 조각이 여전히 #가 붙은 원본인지.
쉽게 뒤집히는 세 가지 말
「해시 뒤가 더 안전하니 전체 문자열을 아무 데나 넘겨도 된다.」 성립하지 않습니다. 서버에는 더 안전하고, 단체 채팅 기록에는 그렇지 않을 수 있습니다. 전체 URL은 자격 증명입니다. 가진 사람이 읽기 페이지를 열고 복호화할 수 있습니다.
「Referer가 키를 외부 링크로 샐 수 있다.」 현행 규격에서 브라우저가 Referer를 만들 때 프래그먼트를 반드시 뗍니다. 실제로 막을 것은 페이지가 location.href를 분석, 로그, 서드파티 스크립트에 쓰는 경우입니다. 확인 방법은 여전히 Network의 전송 내용이며, 「외부 링크가 있으니 키가 샌다」는 가정이 아닙니다.
「읽기 페이지는 먼저 로그인해야 한다. 아니면 누구나 연다.」 「누가 전체 링크를 가졌는가」와 「누가 계정을 가졌는가」를 섞은 말입니다. 일회용 암호문 링크의 접근 제어는 문자열 자체입니다. 수신자에게 로그인 문을 달아도 서버가 키를 못 보게 되는 것은 아닙니다. 키는 원래 서버로 가면 안 됩니다. MakePwd의 읽기 페이지는 수신자에게 공개이며, 만들기와 읽기 모두 계정이 필요 없습니다.
자주 묻는 질문
해시 뒤는 서버로 가나요?
기본으로는 가지 않습니다. RFC 9110은 대상 URI에 프래그먼트가 없다고 적습니다. Network를 열면 문서 요청의 요청 줄에 #와 그 뒤 키가 나오면 안 됩니다. 서버는 설계상 경로와 ?id=만 봅니다.
왜 키를 물음표 뒤에 두지 않나요?
물음표 뒤 쿼리는 HTTP 요청 줄에 들어가고, 호스팅 측과 액세스 로그가 봅니다. 키가 쿼리에 들어가면 「암호문만 둔다」는 말은 로그 층에서 이미 성립하지 않습니다. 위치용 id는 물음표로 가도 되고, 키는 해시로 가야 합니다.
전체 링크를 채팅에 붙여도 안전한가요?
서버는 여전히 키를 보지 못합니다. 채팅 기록, 티켓, 브라우저 기록에는 더 이상 성립하지 않습니다. 전체 URL은 가진 사람이 바로 씁니다. 넘겨야 한다면 상대가 해시가 붙은 원본을 받았는지 확인하고, 그 문자열이 상대 기록에 남는다는 점을 받아들이세요.
읽기 페이지를 열려면 로그인해야 하나요?
필요 없습니다. 읽기 페이지는 수신자에게 공개입니다. 물음표의 id로 암호문을 받고, 해시의 키로 이 기기에서 복호화합니다. MakePwd에는 계정과 비밀번호 보관함이 없으며, 만들기와 읽기 모두 열자마자 씁니다.
다음에 일회용 비밀을 보낼 때 기억할 세 가지
첫째, 「키가 URL 안에 있다」는 빈 문장이 아니라 기호를 보세요. 물음표는 HTTP로 가고, 해시는 기본으로 가지 않습니다. 둘째, 만들 때는 나가는 것이 암호문인지 확인하고, 읽을 때는 요청 줄에 # 뒤가 없는지 확인하세요. 셋째, 전체 링크를 넘기기 전에 물으세요. 이 문자열이 채팅 기록, 티켓, 해시를 버리는 단축 링크에 들어갈지. 뒤의 두 실패는 서버가 영지식인지와 무관합니다.
평문이 업무 데이터로 탭을 떠났는지도 보려면 앞 글의 범위입니다. 카나리아로 Network 요청 본문과 분석 전송을 검색하세요. 이 글은 「키를 해시 뒤에 둘 수 있는 이유」를 규격과 요청 줄에 대조할 수 있는 판단으로만 줄입니다.