일회용 링크를 한 번 읽은 뒤, 서버에 남는 것은 무엇인가

운영이 데이터베이스 비밀번호를 채팅에 넣고, 동료가 복구 코드를 티켓에 붙인 뒤 가장 자주 묻는 말은 「알고리즘 이름이 뭐냐」가 아닙니다. 상대가 연 다음, 그 문자열이 어느 기계에 남아 있느냐입니다. 일회용 링크가 답하는 것은 뒷문장입니다. 서버에는 처음부터 평문이 없었고, 암호문은 가져와 설정 횟수를 채운 뒤에 지워집니다. 채팅 기록에 링크 문자열이 남는 것은 다른 층의 위험입니다.

바로 대조할 수 있는 결론부터 잡으세요. 읽기 페이지를 처음 열면 껍데기만 로드해야 하고, 아직 서버에 암호문을 요청하지 않아야 합니다. 「열고 보기」를 누른 뒤에야 Network에 번호로 암호문을 받는 요청이 나타납니다. 성공해서 횟수를 다 쓴 뒤, 같은 번호를 다시 요청하면 410이어야 합니다. 주소창에는 # 뒤 키가 있어도 됩니다. 요청 줄에는 같은 구간이 나오면 안 됩니다.

「열람 후 삭제」는 자주 이렇게 들립니다. 링크에 불이 붙으면 내용도 사라진다는 뜻. 이 비유는 방향을 뒤집기 쉽습니다. 카카오톡의 파란 글자는 스스로 지워지지 않습니다. 브라우저 기록, 메일 원문, 티켓 댓글도 그대로입니다. 태워지는 것은 서버에 잠시 두었던 그 암호문입니다. 사람이 가진 전체 URL은 여전히 한 줄의 문자입니다. 다만 다시 열면 번호가 가리키던 대상이 이미 없습니다.

그래서 두 가지를 먼저 나눕니다. 하나는 위치 증명입니다. 물음표 뒤 번호는 서버에 어느 기록을 가져올지 알립니다. 다른 하나는 복호화 증명입니다. 해시 뒤 키는 현재 탭에만 남겨 둡니다. 설계상 서버는 전자를 보고, 후자를 보지 못합니다. 한 번 읽은 뒤에도 전자는 이미 빈 자리를 가리킬 수 있습니다. 후자가 채팅 기록에 남아 있어도 풀 대상이 없습니다. 암호문이 이미 없기 때문입니다.

클라우드 드라이브 공유 링크와는 다릅니다. 드라이브 링크가 유효하면 파일은 보통 그대로입니다. 지우는 것은 권한이지 대상 자체가 아닙니다. 일회용 링크는 「읽기」와 「삭제」를 같은 가져오기 한 번에 묶습니다. 만들 때 읽기 횟수(1–10, 기본 1)와 만료(1시간, 24시간, 7일, 또는 읽은 뒤에만 삭제)를 정할 수 있습니다. 기본 경로는 이렇습니다. 처음 성공적으로 가져오면 암호문은 서버에서 삭제됩니다. 읽지 않고 만료되어도 마찬가지로 삭제됩니다. 어느 쪽이든 되찾을 평문 백업은 없습니다.

서버에는 처음부터 평문이 없었습니다

「읽고 나서 평문을 지운다」는 또 다른 흔한 오해입니다. 순서는 이렇습니다. 평문은 만드는 쪽 탭에만 나타납니다. 브라우저는 Web Crypto로 256비트 난수 키를 뽑고 AES-256-GCM으로 암호화합니다. 나가는 필드는 암호문, 만료 시간, 최대 읽기 횟수입니다. 서버는 추측할 수 없는 번호를 돌려줍니다. 페이지는 키를 s.html?id={번호}#{키}의 프래그먼트에 붙입니다. 「먼저 비밀번호를 데이터베이스에 넣고 나중에 암호화한다」는 단계는 없습니다.

알고리즘 파라미터는 구호가 아니라 그 자리에서 대조할 수 있습니다. NIST SP 800-38D는 상호운용과 구현 단순함을 위해 GCM에 96비트(12바이트) IV를 권합니다. 인증 태그는 흔히 128비트(16바이트)입니다. Web Crypto의 AesGcmParams도 여기에 맞습니다. MakePwd가 만들 때 쓰는 암호문 형태는 Base64(12바이트 IV + 암호문 + 16바이트 태그)이며, 키는 32바이트, 한 항목 상한은 32 KB입니다. 이 숫자는 만들기 페이지에도 있고, 개발자 도구에서 방금 만든 링크로 확인할 수도 있습니다. 해시 뒤는 키의 Base64URL이지, 방금 입력한 그 문장이 아닙니다.

GCM은 무결성도 제공합니다. 암호문이나 태그가 바뀌면 이 기기에서 복호화가 실패하고, 「비슷해 보이는」 평문을 내놓지 않습니다. 막는 것은 전송 중 변조입니다. 「상대가 연 뒤 스크린샷을 찍는 일」은 막지 못합니다. 서버는 처음부터 키를 받지 못하므로 대신 복호화할 수도, 내용을 검열할 수도, 읽은 뒤에 평문 사본을 다시 만들어 둘 수도 없습니다. 할 수 있는 일은 하나입니다. 번호로 암호문을 내주고, 횟수나 TTL에 따라 지우는 것입니다.

단계 브라우저에 있는 것 서버에 있는 것
만들기 완료 평문(방금 입력한 것), 전체 링크 암호문, 번호, TTL, 남은 횟수
읽기 페이지를 막 연 직후 주소창의 번호와 # 여전히 암호문. 이번 열기로는 아직 삭제되지 않음
확인 후 가져오기 성공 이 기기에서 복호화한 평문 횟수를 다 쓰면 암호문 삭제. 남으면 횟수 하나 감소
같은 번호를 다시 열기 키가 주소창에 남아 있을 수 있음 이미 삭제되었거나 만료됨. 410 같은 상태

번호는 물음표, 키는 해시

일회용 링크는 동시에 두 가지를 해야 합니다. 서버에 어느 항목을 가져올지 알리고, 수신자 브라우저에 어느 열쇠를 쓸지 알리는 일입니다. 두 일을 같은 HTTP 필드에 끼워 넣으면 안 됩니다. 물음표 뒤 쿼리는 요청 줄에 들어가므로 리버스 프록시와 액세스 로그가 설계상 봅니다. 해시 뒤 프래그먼트는 규격상 클라이언트에 남습니다.

RFC 9110 7.1절은 대상 URI에 프래그먼트가 없다고 적습니다. 프래그먼트 식별자는 클라이언트가 처리하기 때문입니다. 브라우저가 서버에 요청하는 것은 s.html?id=…이지, 주소창 전체 문자열이 아닙니다. 키를 ?key=로 바꾸면 「암호문만 둔다」는 말은 로그 층에서 이미 성립하지 않습니다. 운영이 그날의 access log를 열면 복호화에 필요한 나머지 반쪽을 보게 됩니다.

이 층은 이전 글에서 이미 펼쳤습니다. 여기서는 경계만 남깁니다. 나누는 이유는 호스팅 측이 키를 보지 못하게 하려는 것이지, 전체 링크를 아무 데나 전달해도 된다는 뜻이 아닙니다. #가 붙은 문자열 전체를 채팅에 붙이면 수신자와 채팅 서비스 모두 키를 봅니다. 프래그먼트가 막는 것은 HTTP이지, 클립보드가 아닙니다. 세부와 그 자리에서 대조하는 단계는 URL의 # 뒤에 키를 두기 좋은 이유, 그리고 이 보호가 깨지는 순간을 보세요.

암호문을 가져오는 그 한 번이 삭제 지점입니다

「읽기 페이지를 연다」와 「읽기 횟수를 하나 쓴다」는 같은 사건이 아닙니다. 읽기 페이지 HTML은 먼저 그릴 수 있습니다. 주소에 ?id=가 있는지, # 키가 있는지를 확인한 뒤 확인 버튼에서 멈춥니다. 이 단계는 정적 자원만 씁니다. 서버가 암호문을 지우게 만드는 것은 그다음에 번호로 암호문을 받는 요청입니다. 버튼을 누르고, 가져오기에 성공하고, 횟수를 다 쓴 뒤에야 기록은 「아직 가져올 수 있음」에서 「이미 삭제됨」으로 바뀝니다.

키가 없으면 가져가면 안 됩니다. 주소에 번호만 있고 해시가 카카오톡에서 잘린 경우, 읽기 페이지는 프래그먼트가 없다고 알려야 하며, 먼저 서버에 암호문을 요청하면 안 됩니다. 그렇지 않으면 단 한 번을 낭비합니다. 서버는 암호문을 내주고 지우는데, 브라우저는 풀지 못하고, 보낸 사람은 상대가 이미 읽었다고 생각하게 됩니다. 대조할 수 있는 동작은 이렇습니다. #가 없을 때 Network에 암호문 가져오기 인터페이스가 나타나면 안 됩니다. 전체 링크가 있고 확인을 누른 뒤에야 나타나야 합니다.

횟수가 언제나 1인 것은 아닙니다. 만들 때 1에서 10까지 정할 수 있습니다. 3으로 두면 처음 두 번 성공한 뒤에도 서버의 암호문은 남아 있고, 남은 횟수만 하나 줄어듭니다. 세 번째 성공 뒤에야 삭제됩니다. 기본값 1은 「비밀번호를 한 번 전달한다」는 작업에 맞춘 것이지, 프로토콜이 한 번만 읽을 수 있어서가 아닙니다. TTL은 별도의 삭제 조건입니다. 24시간이 지나 읽지 않아도 암호문은 지워지며, 상태는 「이미 읽힘」과 구분되어야 합니다. MakePwd의 읽기 페이지는 410 응답에서 expired로 만료를 나타내고, 나머지 삭제 상태는 이미 태워진 것으로 처리합니다.

실제 비밀번호, 운영 환경 API Key, 마스킹하지 않은 연결 문자열로 실험하지 마세요. 버려도 되는 카나리아를 준비하세요. 예: canary-burn-2026-do-not-reuse. 대조하는 것은 상태 코드와 요청 줄이지, 비밀을 한 번 더 퍼뜨리는 일이 아닙니다.

410과 404: 태움, 만료, 처음부터 없음

암호문을 지운 뒤 서버는 다음 요청에 답해야 합니다. RFC 9110 15.5.11절410 Gone을 이렇게 적습니다. 대상 자원이 원 서버에서 더 이상 쓸 수 없고, 그 상태가 영구일 가능성이 높다. 원 서버가 영구인지 판단하지 못하면 404를 써야 합니다. MDN의 410 설명은 클라이언트가 반복해서 재시도하지 말 것, 사이트는 그 자원을 가리키는 링크를 거두라고 덧붙입니다.

일회용 링크에는 410이 404보다 맞습니다. 이 번호는 한때 암호문에 대응했고, 지금은 일부러 지웠으며, 다시 돌아오지 않습니다. 만료와 횟수 소진 모두 410으로 가고, 응답 본문으로 expired와 일반 삭제를 나누면 수신자가 「링크를 잘못 썼다」고 오해하지 않습니다. 404도 여전히 나올 수 있습니다. 번호 형식이 잘못되었거나, 기록이 「한때 있었다」는 선언까지 지워진 경우입니다. 보낸 사람에게 둘 다 같은 뜻입니다. 서버에서 내용을 다시 건질 수 없습니다.

410에는 잘 놓치는 층이 하나 더 있습니다. 크롤러에게 「다시 오지 마라」고 알립니다. 읽기 페이지 자체는 임시 암호문 장면이므로 검색엔진에 noindex여야 하고, sitemap에도 넣지 않아야 합니다. 일회용 비밀번호 페이지가 수집되길 원하지 않습니다. 만들기 페이지는 수집될 수 있습니다. 링크를 어떻게 만드는지 설명하기 때문입니다. 읽기 착지 페이지는 전체 URL을 가진 사람만 씁니다.

채팅 미리보기가 횟수를 먼저 태우는 이유

일회용 링크에서 가장 흔한 사고는 AES를 암호학자가 깬 일이 아닙니다. 미리보기 봇이 동료보다 빠른 일입니다. 전체 URL을 메신저에 붙이면, 소프트웨어는 카드를 그리려고 그 주소에 먼저 요청을 보냅니다. 카카오 데브톡의 스크랩 설명은 분명합니다. 사용자가 카카오톡에서 URL을 입력하거나 공유하면, 스크랩 서버가 해당 웹페이지에 직접 접근해 정보를 수집하고 미리보기를 만듭니다. Slack의 링크 펼치기 문서도 같은 부류입니다. 메시지에 링크가 있으면 기본적으로 가져와 미리보기를 제공합니다. Teams, Discord, 일부 메일 보안 검사도 비슷합니다. 그들이 원하는 것은 제목과 요약이지 비밀번호가 아닙니다. 다만 「첫 GET에서 암호문을 가져와 삭제」한다면, 이번 수집이 단 한 번을 써 버립니다. 동료가 들어가면 이미 삭제된 상태만 봅니다.

키가 # 뒤에 있는 것은 이런 서버 측 수집에 대한 한 층입니다. 봇이 요청하는 것은 s.html?id=…이고, 해시 뒤 키는 규격상 HTTP와 함께 나가지 않습니다. 미리보기는 보통 평문을 풀지 못하고, 카드에도 비밀번호가 없습니다. 태우는 것은 가져오기 횟수이지 키 자체가 아닙니다. 읽기 페이지를 로드하는 순간 자동으로 암호문을 받는 구현이라면, 미리보기는 수신자를 대신해 「열기」를 한 번 누른 셈입니다. 수신자는 410을 보고, 보낸 사람은 상대가 이미 읽었다고 생각합니다.

실행 가능한 대책은 링크를 붙이지 말라는 것이 아닙니다. 「페이지를 그린다」와 「암호문을 받는다」를 나누는 것입니다. 읽기 페이지는 먼저 확인 화면을 그리고, 이번 클릭이 횟수를 쓴다고 알린 뒤, 사람이 손을 움직인 다음에 가져오기 요청을 보냅니다. HTML만 가져가고 버튼 클릭을 실행하지 않는 미리보기는 확인 단계에서 멈춥니다. 전체 스크립트를 실행하고 클릭을 흉내 내는 스캐너까지 막지는 못하고, 사람이 잘못 누르는 일도 막지 못합니다. 가장 흔한 「카드가 먼저 한 번 연다」를 기본 실패에서 기본 생존으로 바꿉니다.

다른 실천은 이렇습니다. 자동으로 펼쳐지는 채널에는 먼저 「비밀번호는 일회용 링크로 간다, 링크는 따로 보낸다」고 쓰거나, 미리보기가 없는 개인 메시지로 전체 링크를 보냅니다. 전체 URL은 여전히 가진 사람이 바로 쓰는 자격 증명입니다. 미리보기 문제는 횟수를 추가로 쓰는 일이지, 「문자열 전체를 가진 사람이 복호화할 수 있다」는 층을 바꾸지 않습니다.

그 자리에서 대조: 확인 페이지에서 멈춘 뒤 Network를 검색하세요

「열람 후 삭제, 영지식」이라는 문구만으로는 증명되지 않습니다. 그 자리에서 볼 수 있는 것은 네 가지입니다. 만들 때 나가는 요청에 평문이 있는지. 읽기 페이지를 처음 로드할 때 암호문을 받는지. 확인을 누른 뒤 요청 줄에 키가 있는지. 횟수를 다 쓴 뒤 다시 열면 410인지.

먼저 카나리아를 준비하세요. 일회용 링크 만들기 페이지를 열고, 버려도 되는 시험 문장을 넣고, TTL은 1시간, 읽기 횟수는 1로 두세요. 만든 뒤 누구에게도 보내지 마세요. 결과 영역의 분할을 보세요. 쿼리 쪽에는 번호만 있고, 프래그먼트 쪽에만 키가 있어야 합니다. 그다음 개발자 도구 Network를 열고 Preserve log를 켠 뒤, 시험 문장 전체로 검색하세요. 만들기 요청의 body는 암호문이어야 하고, 그 원문이 나타나면 안 됩니다. 분석 전송에도 원문이 나타나면 안 됩니다.

  1. 같은 전체 링크로 읽기 페이지를 열고, 아직 확인을 누르지 마세요. Network에는 문서와 정적 자원만 있어야 하며, 번호로 암호문을 받는 인터페이스는 나타나면 안 됩니다.
  2. 주소창과 문서 요청의 요청 줄을 대조하세요. 주소창은 #와 그 뒤를 유지합니다. 요청 줄에는 경로와 ?id=만 보여야 합니다.
  3. 확인을 누르세요. 이때야 암호문 가져오기 요청이 나타나야 하고, 성공하면 페이지에 평문이 보입니다. 같은 링크로 두 번째 열면 이미 삭제되었거나 410이어야 하며, 같은 카나리아를 다시 내놓으면 안 됩니다.

세 가지가 모두 맞으면 아주 좁은 결론만 지지합니다. 지금 쓰는 이 브라우저, 이번 조작에서, 평문은 관찰된 업무 필드로 나가지 않았고, 키는 요청 줄에 들어가지 않았으며, 암호문은 한 번 성공한 가져오기 뒤에 더 이상 받을 수 없습니다. 확장 프로그램이 입력칸을 읽지 않았다는 증명은 아니고, 서버 디스크가 깨진 뒤 과거 암호문이 절대 없다는 증명도 아닙니다. 브라우저를 바꾸면 카나리아를 한 번 더 돌릴 가치가 있습니다. 「아무 도구 페이지의 평문이 이 기기를 떠났는지」까지 대조하려면 브라우저에서 암호화할 때, 평문이 나가지 않았는지 직접 확인하는 법의 단계를 보세요.

복사, 스크린샷, 전체 링크 전달은 막지 못합니다

일회용 링크가 줄이는 위험은 두 종류입니다. 서버가 평문을 오래 보관하는 일, 그리고 같은 암호문을 반복해서 여는 일. 세 번째는 줄이지 못합니다. 수신자가 이미 평문을 본 뒤에 무엇을 하느냐입니다. 상대는 복사하고, 스크린샷을 찍고, 전달하고, 옆 사람에게 읽어 줄 수 있습니다. 링크 자체도 통째로 전달될 수 있습니다. 그때는 키와 번호가 함께 가고, 다음 사람도 횟수가 남아 있으면 읽기 페이지에서 똑같이 복호화할 수 있습니다.

그래서 「상대가 로그인해야 열 수 있다」는 말로 인수인계를 해결하지 못합니다. 접근 제어는 전체 URL 그 자체입니다. 읽기 페이지에 계정을 얹어도 서버가 키를 보게 되지는 않습니다. 키는 원래 서버로 가면 안 됩니다. 수신자에게 문만 하나 더 만들고, 「누가 링크를 가졌는가」를 「누가 계정을 가졌는가」로 바꿀 뿐입니다. MakePwd에는 계정과 비밀번호 보관함이 없습니다. 만들기 페이지와 읽기 페이지 모두 열자마자 쓰며, 읽기 페이지는 수신자에게 공개입니다.

용량에도 경계가 있습니다. 32 KB면 비밀번호, API Key, 복구 코드, 짧은 설명에는 충분하고, 데이터베이스 덤프나 인증서 묶음 전체에는 부족합니다. 파일 전체는 이 기기에서 .lock / .enc로 만든 뒤 드라이브나 메일로 보내세요. 파일 암호화의 파일당 상한은 5 GB이며, 마찬가지로 열자마자 쓰고 평문은 기본으로 올리지 않습니다. 큰 파일을 일회용 링크에 넣는 것은 「더 안전하다」가 아니라, 이 짧은 텍스트 통로의 설계를 넘는 일입니다.

열자마자 쓰는 만들기 페이지로 삭제 상태를 익히세요

결과 영역에 분할을 적어 주는 도구로 연습하고 싶다면 MakePwd의 일회용 링크부터 시작할 수 있습니다. 열자마자 쓰며, 양쪽 모두 가입이 필요 없습니다. 만들 때 암호화는 현재 탭에서 일어나고, 알고리즘은 AES-256-GCM입니다. 서버가 받는 것은 암호문, ttl_hours(0–168, 기본 24), max_reads(1–10, 기본 1)뿐입니다. 만들어지는 링크 형태는 s.html?id={번호}#{키}로 고정입니다. 읽기 페이지는 확인 단계에서 멈춘 뒤 암호문을 받아 이 기기에서 복호화합니다.

연습할 때는 위의 카나리아를 쓰고, 읽기 횟수는 1로 두세요. 끝난 뒤 세 곳을 같이 보세요. 만들기 결과 영역의 쿼리 / 프래그먼트 분할, 읽기 페이지를 처음 로드할 때 Network가 암호문을 미리 받는지, 두 번째 열기가 삭제 상태로 들어가는지. 분할은 키가 물음표에 들어가지 않았는지 확인합니다. 첫 로드는 미리보기가 잘못 태우지 않는지 확인합니다. 두 번째 열기는 「읽으면 삭제」가 문구가 아닌지 확인합니다.

보내기 전에 추적 파라미터를 먼저 벗겨야 한다면, 정제부터 한 뒤 진짜 비밀로 남겨야 하는 짧은 텍스트만 일회용 링크에 넣으세요. 정제가 푸는 것은 쿼리의 UTM과 클릭 ID이지, 일회용 암호문 통로를 대체하지 않습니다. 이 단계들은 로그인을 요구하지 않고, 연결되지 않은 고객 이메일도 보여 주지 않습니다.

자주 묻는 질문

한 번 연 뒤, 서버에 평문이 남나요?

처음부터 평문은 없습니다. 만들 때 브라우저가 먼저 암호화하고, 서버는 암호문만 받습니다. 성공적으로 가져와 설정 횟수를 다 쓰면 암호문은 삭제됩니다. 같은 번호를 다시 요청하면 410이거나 이미 삭제된 상태입니다. 링크 문자열이 채팅 기록에 남을 수는 있습니다. 그것은 서버의 평문 사본이 아닙니다.

채팅 미리보기가 내용을 먼저 태우나요?

횟수는 태우고, 평문은 보통 태우지 못합니다. 미리보기가 가져가는 것은 페이지이고, 키는 # 뒤에 있어 HTTP에 들어가지 않습니다. 읽기 페이지가 로드할 때 암호문을 받으면 미리보기가 단 한 번을 씁니다. 맞는 구현은 확인 뒤에 가져오는 것입니다. 카카오톡은 URL을 입력하거나 공유하면 스크랩 서버가 페이지를 가져와 미리보기를 만듭니다.

410과 404는 무엇이 다른가요?

410은 이 자원이 한때 쓸 수 있었고 지금은 영구히 없어졌다는 뜻이며, 클라이언트는 재시도하지 않아야 합니다. 404는 더 모호합니다. 번호가 잘못되었거나 기록이 이미 지워진 경우일 수 있습니다. 일회용 링크는 410으로 삭제와 만료를 나누는 편이 더 분명합니다. 어느 쪽이든 복구할 평문은 없습니다.

읽기 페이지를 열려면 로그인해야 하나요?

필요 없습니다. 만들기와 읽기 모두 열자마자 씁니다. 수신자는 번호로 암호문을 받고, 해시의 키로 이 기기에서 복호화합니다. MakePwd에는 계정과 비밀번호 보관함이 없습니다. 접근 제어는 전체 링크 그 자체입니다.

다음에 일회용 비밀을 보낼 때 기억할 세 가지

첫째, 「읽고 나서 무엇이 남나」를 물을 때 먼저 서버에 평문이 있었는지를 물으세요. 없었다면 지우는 것은 암호문입니다. 링크가 채팅에 남아 있어도 풀 대상이 없습니다. 둘째, 삭제 지점은 암호문을 가져오는 그 요청이지, 읽기 페이지를 처음 그리는 일이 아닙니다. 확인 버튼이 막는 것은 미리보기 봇이지, 이미 문자열 전체를 가진 사람이 아닙니다. 셋째, 대조는 Network를 보세요. 만들 때 카나리아 원문을 검색하고, 읽을 때 요청 줄과 #를 대조하고, 두 번째 열기에서 410을 보세요.

키가 왜 해시 뒤에 있어도 되는지 이어서 묻고 싶다면 URL의 # 뒤에 키를 두기 좋은 이유, 그리고 이 보호가 깨지는 순간을 읽으세요. 이 글은 「한 번 읽은 뒤 서버에 남는 것」을 상태 코드와 요청 시점으로 대조할 수 있는 범위로만 정리합니다.