예전에는 나만의 인증 레이어를 직접 만드는 것이 일종의 훈장이라고 생각했습니다. JWT를 이해하고 비밀번호를 해싱할 줄 안다면, 그게 뭐 그리 어렵겠나 싶었죠. Node 백엔드를 구축하고, 토큰을 발행하고, 그걸로 끝이라고 생각했습니다. 코드는 작동했고, 테스트도 통과했습니다. 하지만 실제 공격이 어떻게 이루어지는지에 대해 읽기 시작하면서, 제가 쌓아온 기반이 무너져 내리는 것을 느꼈습니다. 인젝션, 열거(enumeration), 사이드 채널 유출에 관한 모든 장을 읽을 때마다 절망적인 기분으로 에디터 앞에 앉아 있었습니다. 제 인증 시스템은 단순히 빈틈이 있는 수준이 아니었습니다. 제가 직접 설치한 네 개의 활짝 열린 문이 있었습니다. 제가 발견한 문제들과 정확히 무엇을 수정했는지 공유하겠습니다.

문자열 연결을 통한 SQL 인젝션

첫 번째 버그는 가장 고전적인 수법이었습니다. 사용자 입력을 받아 SQL 문자열에 그대로 집어넣고 있었습니다. 로그인 라우트에서 요청 본문(request body)으로부터 이메일을 가져와 SELECT * FROM users WHERE email = '${email}'와 같은 쿼리에 연결했습니다. 프론트엔드를 제가 제어하고 있었기에 아무런 해가 없다고 느꼈습니다. 하지만 그것은 위험한 가정이었습니다. 공격자에게는 여러분의 프론트엔드가 필요하지 않습니다. 정교하게 조작된 단 하나의 POST 본문만으로도 해당 로그인 확인 절차를 데이터 유출이나 데이터베이스 삭제로 바꿀 수 있습니다. ' OR '1'='1과 같은 페이로드를 보내거나, 더 나아가 테이블을 삭제하는 스택형 쿼리(stacked query)를 보내면, 문자열이 가공 없이 실행될 경우 데이터는 모두 사라집니다. 저는 데이터베이스가 제 코드와 공격자의 데이터를 구분할 수 있는 방법을 전혀 제공하지 않았던 것입니다.

해결책은 더 많은 입력값 검증이나 수동으로 문자열을 이스케이프(escaping)하는 것이 아니었습니다. 진짜 해결책은 매개변수화된 쿼리(parameterized queries)를 사용하는 것이었습니다. 저는 node-postgres로 전환하고 $1과 같은 플레이스홀더를 사용하기 시작했습니다. 쿼리는 템플릿이 됩니다: SELECT * FROM users WHERE email = $1. 드라이버는 SQL과 값을 별도의 채널을 통해 전송합니다. 데이터베이스는 입력값에 어떤 문자가 포함되어 있든 이를 엄격하게 데이터로만 취급합니다. 이 한 번의 변화로 인젝션 공격이라는 클래스 전체를 차단할 수 있습니다. 코드가 읽기 더 쉬워지고 유지보수도 간편해지며, WHERE 절을 작성할 때마다 정규표현식 마법사가 되어야 하는 부담도 사라집니다.

에러 메시지를 통한 이메일 열거 (Email Enumeration)

두 번째 실수는 좋은 UX처럼 보였습니다. 사용자가 잘못된 이메일을 입력하면 User not found를 반환했습니다. 이메일은 맞지만 비밀번호가 틀리면 Incorrect password를 반환했습니다. 도움이 된다고 생각했습니다. 하지만 이는 공격자에게는 정찰 도구가 되었습니다. 열거 스크립트는 수천 개의 이메일 주소로 로그인 엔드포인트를 몰아칠 수 있습니다. 계정 존재 여부에 따라 응답 본문이나 상태 코드가 달라진다면, 스크립트는 여러분의 사용자 목록을 검증된 상태로 구축할 수 있습니다. 그 목록은 크리덴셜 스터핑(credential stuffing), 타겟 피싱, 그리고 추가적인 무차별 대입 공격(brute-force)의 기반이 됩니다.

사용자 편의성이 때로는 보안에 양보해야 한다는 사실을 받아들여야 했습니다. 저는 모든 로그인 실패 경로가 정확히 동일한 문자열인 Invalid credentials를 반환하도록 변경했습니다. 힌트도 주지 않았고, 에러 응답에 분기 로직도 넣지 않았습니다. 이메일이 없든, 비밀번호가 틀렸든, 계정이 잠겼든 상관없이 텍스트는 동일하게 유지됩니다. 이는 회원가입 및 비밀번호 재설정 흐름에도 적용됩니다. 특정 이메일 주소가 이미 시스템에 있는지 여부를 드러내지 마세요. 하나의 일반적인 메시지만으로 공격자가 의존하는 정보 유출을 차단할 수 있습니다.

비밀번호 비교 시 발생하는 타이밍 공격 (Timing Attacks)

세 번째 버그는 눈에 보이지 않았습니다. 저는