이미지 url 네트워크 탭에서 감추기
문제해결 | | 8분 읽기

문제 정의
로그인 후 OTP 설정 시 백엔드가 반환하는 { qrImageUrl } 를 클라이언트에 노출하면 안 됨.
브라우저 네트워크 탭에서 보이면 보안 위협
1. 서버사이드 마이그레이션 필요 여부 검토
OTP 정보를 서버에서만 처리하려면 서버사이드가 필요한데, 로그인 자체를 서버 컴포넌트로 마이그레이션해야 하는지 확인
→ 불필요. 백엔드가 로그인 성공 시 임시 세션 쿠키를 심어주고, Next.js Route Handler는 이 쿠키에 접근할 수 있어서 로그인 마이그레이션 없이 서버사이드 처리 가능.
2. API 라우트 핸들러 이용
Route Handler에서 백엔드 응답의 qrImageUrl로 이미지를 fetch하고, base64로 변환해 JSON으로 반환.
// Route Handler
const response = await fetch(`/auth/otp/registration`, {
method: 'POST',
headers: {
Cookie: `service.sid=${authToken}`,
},
});
const data = await response.json();
return NextResponse.json({ qrImage: data.qrImageUrl });→ 미채택**.** 네트워크 탭의 /api/auth/otp/registration 응답에 qrImage 필드(base64 문자열)가 JSON으로 그대로 노출되는 구조. qrImageUrl 자체가 클라이언트에 전달되므로 근본적인 해결이 안 됨. 클라이언트에서 호출하는 것과 API 라우트를 사용한 것에 대한 큰 이점이 없음
3. 서버 컴포넌트 검토
서버 컴포넌트에서 렌더링하면 qrImageUrl을 숨길 수 있는지 검토.
→ 불가. 서버 컴포넌트가 base64로 변환해도 RSC payload에 직렬화되어 네트워크 탭에 노출됨. <img src={qrImageUrl}> URL 자체가 HTML에 포함됨.
4. blob을 이용하여 이미지 요청을 클라이언트 → 서버로 위임
// Route Handler
const response = await fetch(`/auth/otp/registration`, {
method: 'POST',
headers: {
Cookie: `service.sid=${authToken}`,
},
});
const data = await response.json();
const qrImageResponse = await fetch(data.qrImageUrl);
const qrImageBuffer = await qrImageResponse.arrayBuffer();
const base64 = Buffer.from(qrImageBuffer).toString('base64');
return NextResponse.json({ qrImage: `data:image/png;base64,${base64}` });→ 미채택. arrayBuffer()로 전체를 메모리에 올린 뒤 base64로 변환하면 불필요한 메모리 사용이 생기고, 결국 JSON 응답에 base64 문자열이 포함되는 구조는 2단계와 동일하게 값이 네트워크 탭에 노출됨.
→ 버퍼링 없이 바이너리를 바로 스트리밍하는 5단계로 개선.
5. PNG 바이너리 스트림 반환 (확정)
Route Handler에서 qrImageUrl을 서버에서만 소비하고, 이미지 데이터를 PNG 바이너리 그대로 응답으로 흘려보냄
// Route Handler (서버)
const qrImageResponse = await fetch(data.qrImageUrl); // qrImageUrl은 서버에서만 소비
return new NextResponse(qrImageResponse.body, { // PNG 바이너리만 전달
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-store'
},
});
// 클라이언트
const res = await fetch('/api/auth/otp/registration', { method: 'POST' });
const blob = await res.blob();
setQrImage(URL.createObjectURL(blob)); // PNG 바이너리 → Blob → ObjectURL네트워크 탭에 /api/auth/otp/registration 응답이 image/png 바이너리로만 찍히고, qrImageUrl은 서버 메모리에서만 처리되고 사라짐.
<요약>
[문제]
- OTP 등록을 위한 QR 이미지 요청 시, 클라이언트가 이미지 원본 URL을 직접 전달받는 경우 네트워크 탭에 해당 URL이 그대로 노출되어 보안상 이슈 발생
- Route Handler를 사용하더라도 클라이언트에서 이미지 요청을 수행하는 구조에서는 여전히 네트워크 응답이나 img src를 통해 URL이 그대로 노출되는 문제 발생
[해결]
- Route Handler를 BFF(Backend for Frontend) 프록시로 활용하여, 클라이언트가 원본 이미지 URL을 직접 호출하지 않도록 구조를 변경
// Route Handler
const data = await response.json();
/** qrImageUrl을 서버에서 fetch → body 스트림을 브라우저로 직접 전달
* qrImageUrl은 서버에서만 소비되며 클라이언트에 노출되지 않음
*/
const qrImageResponse = await fetch(data.qrImageUrl);
return new NextResponse(qrImageResponse.body, {
headers: {
'Content-Type': qrImageResponse.headers.get('content-type') ?? 'image/png',
'Cache-Control': 'no-store',
},
});Route Handler가 백엔드로부터 qrImageUrl을 받아 클라이언트 대신 원본 이미지 URL을 요청하고, 이를 PNG 바이너리 형태로 클라이언트에 응답합니다.
이 과정에서 클라이언트 네트워크 탭에는 image/png 바이너리만 노출되고, 실제 이미지 URL은 서버에서만 처리됩니다.
// 클라이언트
fetch('/api/auth/otp/registration', { method: 'POST' })
.then((res) => res.blob())
.then((blob) => {
const objectUrl = URL.createObjectURL(blob);
setQrImage(objectUrl);
})클라이언트에서는 응답받은 바이너리 데이터를 Blob으로 변환한 뒤, URL.createObjectURL()을 통해 임시 URL(blob:...)을 img 요소에 주입하야 렌더링합니다.
사용이 끝난 후에는 URL.revokeObjectURL()을 호출하여 메모리 누수를 방지하도록 처리했습니다.