직접 만든 로그인에서 흔히 새는 곳: 세션 고정·계정 열거·재설정 토큰
로그인을 직접 구현할 때 예외 경로에서 생기는 보안 구멍 여섯 가지를 세션 고정, 계정 열거, 비밀번호 저장, 재설정 토큰 순으로 점검합니다.
로그인은 정상 경로에서 뚫리지 않는다
로그인 기능을 직접 만들면 대개 "아이디와 비밀번호가 맞으면 통과"까지는 어렵지 않게 완성됩니다. 문제는 그 바깥입니다. 비밀번호를 틀렸을 때, 재설정 링크를 두 번 눌렀을 때, 로그인 직후 세션을 발급할 때 같은 예외 경로에서 구멍이 생깁니다.
AI 코딩 도구로 만들면 이 부분이 특히 잘 빠집니다. "로그인 기능 만들어줘"라는 요청에는 정상 경로만 들어 있기 때문입니다. 자주 새는 곳을 순서대로 짚습니다.
1. 세션 고정: 로그인할 때 세션 ID를 새로 발급하지 않는다
로그인 전에 이미 세션이 만들어져 있고(장바구니, 언어 설정 등), 로그인 성공 시 그 세션에 사용자 정보만 채우는 구현이 있습니다. 이 경우 공격자가 미리 알고 있는 세션 ID를 피해자에게 심어두면, 피해자가 로그인한 뒤 그 세션이 그대로 인증된 세션이 됩니다.
대응은 간단합니다. 인증 상태가 바뀌는 순간 세션 식별자를 새로 발급하세요.
// 로그인 성공 직후
await db.delete(sessions).where(eq(sessions.id, oldSessionId)); // 기존 세션 폐기
const token = crypto.randomBytes(32).toString("base64url"); // 새 토큰 발급
권한이 올라가는 지점(예: 관리자 모드 진입)에서도 같은 원칙이 적용됩니다.
2. 계정 열거: 응답이 계정 존재를 알려준다
로그인 실패 시 "존재하지 않는 이메일입니다"와 "비밀번호가 틀렸습니다"를 구분해 보여주면, 공격자는 그 차이만으로 어떤 이메일이 가입돼 있는지 수집할 수 있습니다. 회원가입에서 "이미 사용 중인 이메일입니다"를 즉시 보여주는 것도 같은 정보를 노출합니다.
메시지를 하나로 합치는 것이 기본입니다.
이메일 또는 비밀번호가 올바르지 않습니다.
비밀번호 재설정은 특히 주의해야 합니다. 가입되지 않은 주소를 넣었을 때 "가입 이력이 없습니다"라고 답하면 그대로 조회 도구가 됩니다. 계정이 있든 없든 같은 화면을 보여주고, 메일은 계정이 있을 때만 보내는 방식이 표준적인 처리입니다.
응답 시간도 정보입니다. 계정이 없을 때 비밀번호 해시 검증을 건너뛰면 응답이 눈에 띄게 빨라집니다. 계정이 없어도 더미 해시로 검증을 한 번 수행해 시간을 맞추세요.
const user = await findByEmail(email);
// 계정이 없어도 같은 비용을 들여 타이밍 차이를 없앤다
const hash = user?.passwordHash ?? DUMMY_HASH;
const ok = await verifyPassword(password, hash);
if (!user || !ok) return fail(); // 실패 사유는 구분하지 않는다
편의를 위해 이메일 중복 확인을 제공해야 한다면, 그 경로에는 반드시 요청 제한을 걸어야 합니다.
3. 비밀번호 저장: 범용 해시를 쓴다
SHA-256 같은 범용 해시 함수는 빠르게 계산되도록 설계됐습니다. 비밀번호 저장에는 그 반대, 즉 의도적으로 느린 함수가 필요합니다. bcrypt, scrypt, Argon2 계열을 쓰세요. 솔트는 이 함수들이 내부적으로 처리합니다.
직접 솔트를 붙여 SHA-256을 여러 번 돌리는 구현을 종종 보는데, 검증된 알고리즘을 쓰는 것보다 나을 이유가 없습니다.
4. 재설정 토큰: 재사용되거나 만료되지 않는다
비밀번호 재설정 토큰에서 자주 빠지는 것이 네 가지입니다.
- 일회성: 사용 후 즉시 폐기. 링크를 다시 눌러도 통하지 않아야 합니다.
- 만료: 짧게(수십 분 수준) 잡습니다.
- 충분한 무작위성: 순차 ID나 타임스탬프 기반이면 예측됩니다. 암호학적 난수를 쓰세요.
- 저장 시 해시: 세션 토큰과 같은 이유입니다. 데이터베이스가 유출됐을 때 그 값으로 바로 재설정되지 않게 합니다.
그리고 재설정이 완료되면 그 사용자의 다른 모든 세션을 폐기해야 합니다. 계정을 탈취당한 사용자가 비밀번호를 바꿨는데 공격자의 세션이 살아 있으면 아무것도 해결되지 않습니다. 비밀번호 변경, 이메일 변경도 같습니다.
5. 요청 제한이 없다
비밀번호를 무한히 시도할 수 있으면 나머지 방어가 의미를 잃습니다. 제한은 두 축으로 걸어야 합니다.
계정 단위로 걸면 한 계정에 대한 대량 시도를 막습니다. 다만 이것만 두면 공격자가 특정 계정을 일부러 잠가버리는 서비스 거부가 가능해집니다. 그래서 완전 잠금보다는 점증적 지연이나 추가 인증 요구가 안전합니다.
IP·클라이언트 단위로도 걸어야 합니다. 흔한 비밀번호 하나로 여러 계정을 훑는 시도는 계정 단위 제한을 통과하기 때문입니다.
제한 대상은 로그인만이 아닙니다. 재설정 메일 발송, 인증 코드 확인, 이메일 중복 확인 모두 포함해야 합니다.
6. 쿠키 속성과 리다이렉트 파라미터
세션 쿠키에는 HttpOnly, Secure, SameSite를 모두 지정하세요. 하나라도 빠지면 각각 스크립트 유출, 평문 전송, 외부 사이트발 요청이라는 경로가 열립니다.
그리고 로그인 후 돌아갈 주소를 쿼리 파라미터로 받는 구조라면(?next=...) 그 값을 검증해야 합니다. 검증 없이 리다이렉트하면 우리 도메인 링크로 외부 사이트에 보내는 통로가 됩니다.
// 외부 절대 URL 과 프로토콜 상대 URL(//evil.com) 을 모두 배제
const next = param.startsWith("/") && !param.startsWith("//") ? param : "/";
점검 순서
- 로그인 성공 시 세션 식별자를 새로 발급하는가
- 로그인·가입·재설정 응답이 계정 존재를 구분하지 않는가
- 계정이 없을 때도 해시 검증 비용을 들여 응답 시간을 맞추는가
- 비밀번호를 bcrypt·scrypt·Argon2 계열로 저장하는가
- 재설정 토큰이 일회성·만료·난수·해시 저장 네 조건을 만족하는가
- 비밀번호·이메일 변경 시 기존 세션을 전부 폐기하는가
- 로그인과 메일 발송에 계정·IP 양쪽 요청 제한이 있는가
- 쿠키에 HttpOnly·Secure·SameSite가 모두 있는가
- 리다이렉트 파라미터를 내부 경로로 제한하는가
소셜 로그인만 쓰면 이 항목들이 필요 없나요?
비밀번호 저장과 재설정 흐름은 사라집니다. 큰 감면입니다. 하지만 세션 고정, 쿠키 속성, 리다이렉트 검증, 요청 제한은 그대로 남습니다. 로그인 상태를 유지하는 일은 여전히 우리가 하기 때문입니다. 게다가 소셜 로그인에는 콜백 처리에서 state 파라미터를 검증해야 하는 항목이 새로 추가됩니다.
인증 라이브러리를 쓰면 안전한가요?
기본값이 훨씬 낫습니다. 세션 발급, 쿠키 속성, 토큰 생성 같은 부분을 검증된 구현으로 대체할 수 있습니다. 다만 요청 제한, 계정 열거 방지 문구, 리다이렉트 검증처럼 애플리케이션 정책에 속하는 것들은 여전히 직접 확인해야 합니다. 라이브러리를 쓰더라도 위 점검 목록을 한 번 훑어보는 편이 좋습니다.
이 중에 하나만 먼저 고친다면?
요청 제한입니다. 나머지 항목은 특정 조건이 맞아야 악용되지만, 시도 횟수 제한이 없는 로그인은 조건 없이 계속 두드려볼 수 있습니다. 그다음은 비밀번호 저장 방식, 그다음이 재설정 토큰 처리입니다.
직접 눌러봐야 드러난다
이 항목들은 코드를 읽어서는 잘 안 보입니다. 만료된 링크를 다시 눌러보고, 없는 이메일로 재설정을 요청해보고, 비밀번호를 바꾼 뒤 다른 브라우저의 세션이 살아 있는지 확인하는 식으로 직접 시험해야 확인됩니다.
로그인이 붙은 프로젝트를 만들었다면 Vibeollio에 프로젝트 등록해서 공개해보세요. 여러 사람이 실제로 가입하고 로그인하는 과정에서, 혼자 테스트할 때는 밟지 않던 경로가 드러나는 경우가 많습니다.
