로그인 보안 체크리스트: 출시 전 확인할 12가지
로그인 보안 체크리스트 12가지를 정리했습니다. 계정 존재 여부 노출, 무차별 대입 제한, 재설정 토큰 재사용, 세션·JWT 만료, 소셜 로그인 계정 연결을 출시 전에 직접 점검하는 방법과 수정 코드를 함께 담았습니다.
직접 만든 로그인·회원가입·비밀번호 재설정 기능은 아래 12가지만 출시 전에 확인해도 실제 진단에서 자주 나오는 문제를 대부분 막을 수 있습니다. 화면이 정상적으로 동작하는지보다 실패하는 경우와 경계 상황에서 서버가 어떻게 반응하는지를 보는 것이 핵심입니다.
- 로그인·가입·재설정 응답 문구로 계정 존재 여부가 드러나지 않는가
- 응답 시간과 상태 코드로도 계정 존재 여부가 드러나지 않는가
- 로그인 실패 횟수가 IP뿐 아니라 계정 기준으로도 제한되는가
- 인증번호 검증과 재설정 메일 발송 요청에도 횟수 제한이 있는가
- 비밀번호를 Argon2id나 bcrypt 같은 느린 해시로 저장하는가
- 비밀번호 길이 정책이 현재 권고에 맞는가
- 재설정 토큰이 충분히 무작위이고, 한 번만 쓸 수 있으며, 만료되는가
- 재설정 링크의 도메인을 요청 헤더가 아닌 설정값으로 만드는가
- 비밀번호를 바꾸면 기존 세션과 리프레시 토큰이 무효화되는가
- 로그인 시 세션 ID를 새로 발급하고, 로그아웃하면 서버에서 세션을 폐기하는가
- JWT에 만료가 있고, 서명 알고리즘이 고정되어 있으며, 폐기할 방법이 있는가
- 소셜 로그인 계정을 이메일만으로 자동 연결하지 않는가
관리자 화면은 관리자 페이지 보안 체크리스트에서 따로 다뤘습니다. 이 글은 일반 사용자 인증 흐름만 다룹니다. 예시 코드는 Node.js 기반의 가상 코드이고, 점검은 반드시 본인 서비스의 스테이징 환경에서 하시기 바랍니다.
계정 존재 여부 노출 (1~2번)
로그인할 때 "가입되지 않은 이메일입니다"와 "비밀번호가 틀렸습니다"를 구분해 보여주면, 공격자는 유출된 이메일 목록 중 어떤 이메일이 우리 서비스에 가입되어 있는지 걸러낼 수 있습니다. OWASP Authentication Cheat Sheet는 로그인, 비밀번호 재설정, 회원가입 모두에서 계정 유무와 관계없이 같은 응답을 주라고 권고합니다. 화면 문구뿐 아니라 HTTP 상태 코드와 응답 시간에서 차이가 나도 계정 존재 여부가 드러난다는 점도 함께 짚습니다.
// 취약: 문구, 상태 코드, 처리 시간이 모두 다르다
const user = await db.user.findByEmail(email);
if (!user) return res.status(404).json({ error: "가입되지 않은 이메일입니다" });
if (!(await argon2.verify(user.passwordHash, password)))
return res.status(401).json({ error: "비밀번호가 틀렸습니다" });
// 개선: 계정이 없어도 해시 검증을 한 번 수행해 처리 시간을 맞춘다
const DUMMY_HASH = await argon2.hash("timing-equalizer-only");
const user = await db.user.findByEmail(email);
const ok = await argon2.verify(user?.passwordHash ?? DUMMY_HASH, password);
if (!user || !ok) {
return res.status(401).json({ error: "이메일 또는 비밀번호가 올바르지 않습니다" });
}
비밀번호 재설정도 같은 원칙을 따릅니다. 화면에는 항상 "해당 이메일로 가입된 계정이 있으면 안내 메일을 보냈습니다"라고만 보여주고, 메일 발송은 큐로 넘겨 계정이 있든 없든 응답 시간이 비슷하게 유지되도록 합니다.
확인 방법: 가입된 이메일과 가입되지 않은 이메일로 로그인과 재설정을 각각 10번씩 요청해 보세요. 그리고 브라우저 개발자 도구에서 상태 코드, 응답 본문, 응답 시간을 비교합니다.
판단 기준: 회원가입 화면의 "이미 사용 중인 이메일" 안내는 사용성과 맞바꿔야 하는 부분입니다. 숨기기 어렵다면 그 API에만 별도로 강한 요청 횟수 제한을 거는 것이 현실적인 절충안입니다.
무차별 대입과 인증번호 추측 제한 (3~4번)
IP 기준으로만 요청 횟수를 제한하면 IP를 바꿔 가며 시도하는 크리덴셜 스터핑을 막지 못합니다. OWASP는 실패 횟수를 IP가 아니라 계정에 묶어 세라고 권고합니다. 또 지연 시간을 점점 늘리는 방식을 쓰거나, 실패가 몇 번 쌓인 뒤에 CAPTCHA를 보여주는 방식을 함께 쓰라고 제안합니다.
다만 영구 잠금은 신중해야 합니다. 누군가 일부러 남의 계정을 잠가 버리는 서비스 거부 수단이 될 수 있기 때문입니다. OWASP도 계정이 잠겨 있어도 비밀번호 재설정은 할 수 있게 하라고 권합니다.
실제 진단에서 더 자주 발견되는 것은 로그인 쪽이 아니라 인증번호 검증 API입니다. 6자리 숫자 코드는 경우의 수가 100만 개뿐인데, 시도 횟수 제한이 없으면 짧은 시간 안에 모든 경우를 대입할 수 있습니다.
// 개선: 코드마다 시도 횟수를 저장하고, 초과하면 코드 자체를 폐기한다
const otp = await db.otp.findActive(userId);
if (!otp || otp.attempts >= 5) return res.status(400).json({ error: "인증번호를 다시 요청해 주세요" });
await db.otp.incrementAttempts(otp.id);
if (!timingSafeEqual(Buffer.from(otp.code), Buffer.from(input))) return res.status(400).json({ error: "인증번호가 올바르지 않습니다" });
await db.otp.delete(otp.id);
재설정 메일과 SMS를 보내는 API에도 계정별 횟수 제한을 거세요. 그래야 특정 사용자에게 메일이나 문자를 대량으로 보내는 악용과 발송 비용 증가를 막을 수 있습니다.
확인 방법: 스테이징 환경에서 같은 계정으로 틀린 비밀번호나 인증번호를 20번 연속 보내 보세요. 몇 번째 요청부터 차단이나 지연이 걸리는지 확인합니다.
비밀번호 저장과 길이 정책 (5~6번)
OWASP Password Storage Cheat Sheet는 Argon2id를 1순위로 권장합니다. 최소 설정은 메모리 19MiB, 반복 횟수 2, 병렬도 1입니다. 기존 시스템에서 bcrypt를 쓴다면 work factor를 10 이상으로 하고, 입력이 72바이트로 제한된다는 점을 고려하라고 합니다.
국내 서비스라면 개인정보 보호 법령상 비밀번호를 일방향 암호화로 저장해야 하는 의무도 있습니다. 관련 구현 항목은 개인정보 안전성 확보조치 기준, 개발자가 구현할 항목에 정리해 두었습니다.
길이 정책은 2025년 7월 확정된 NIST SP 800-63B-4를 따르는 것이 좋습니다. OWASP Authentication Cheat Sheet는 이 문서를 인용해 다음과 같이 권고합니다.
- MFA가 없으면 15자 미만, MFA가 있으면 8자 미만을 약한 비밀번호로 봅니다.
- 최대 길이는 64자 이상 허용합니다.
- 비밀번호를 몰래 잘라 저장하지 않습니다.
- 대소문자·특수문자를 섞도록 강제하는 조합 규칙은 두지 않습니다.
"8~16자, 특수문자 필수" 같은 오래된 정책은 사용자에게 불편만 주고 보안 효과는 크지 않습니다. 대신 흔하거나 이미 유출된 비밀번호 목록과 대조해 걸러내는 방식을 권합니다.
비밀번호 재설정 흐름 (7~9번)
재설정 기능은 진단할 때 가장 많은 문제가 한꺼번에 나오는 곳입니다. 흔히 발견되는 문제는 다음과 같습니다.
// 취약
const token = Math.random().toString(36).slice(2); // 예측 가능한 난수
await db.resetToken.create({ userId, token }); // 평문 저장, 만료 없음
sendMail(`https://${req.headers.host}/reset?token=${token}`); // Host 헤더를 신뢰
// 사용 시: 조회만 하고 삭제하지 않아 같은 링크를 계속 쓸 수 있다
const row = await db.resetToken.findByToken(token);
await db.user.updatePassword(row.userId, newPassword);
// 개선
import { randomBytes, createHash } from "node:crypto";
const sha256 = (v: string) => createHash("sha256").update(v).digest("hex");
const raw = randomBytes(32).toString("base64url");
await db.resetToken.deleteByUser(userId); // 이전 토큰은 모두 폐기
await db.resetToken.create({
userId,
tokenHash: sha256(raw), // DB에는 해시만 저장
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
});
sendMail(`${process.env.APP_ORIGIN}/reset?token=${raw}`); // https://example.com 고정
토큰을 사용할 때는 조회와 폐기를 하나의 쿼리로 처리해야 합니다. 그래야 같은 링크로 요청이 동시에 들어와도 두 번 쓰일 수 없습니다.
DELETE FROM reset_tokens
WHERE token_hash = $1 AND expires_at > now()
RETURNING user_id;
OWASP Forgot Password Cheat Sheet가 권하는 나머지 항목도 함께 확인하세요.
- 재설정 페이지에
Referrer-Policy: no-referrer를 설정해 URL 속 토큰이 외부 사이트로 새지 않게 합니다. - 비밀번호를 바꾼 뒤 자동으로 로그인시키지 않습니다.
- 비밀번호를 바꾸면 기존 세션을 무효화하거나, 사용자에게 무효화할지 선택하게 합니다.
필자는 세션 무효화에 리프레시 토큰까지 포함해 기본값으로 모두 폐기하도록 권합니다. 비밀번호를 바꾸는 이유 대부분이 "누군가 내 계정에 들어온 것 같아서"이기 때문입니다.
확인 방법: 다음 세 가지를 직접 해 보세요.
- 한 번 사용한 재설정 링크를 다시 열어 봅니다.
- 재설정 메일을 두 번 요청한 뒤 첫 번째 메일의 링크를 써 봅니다.
- 다른 브라우저에 로그인해 둔 상태에서 비밀번호를 바꾸고, 그 브라우저가 여전히 로그인되어 있는지 확인합니다.
세션과 JWT 만료 (10~11번)
세션 방식이라면 OWASP Session Management Cheat Sheet의 세 가지 원칙을 확인하세요.
- 로그인할 때 세션 ID를 새로 발급합니다. 로그인 전에 쓰던 세션 ID를 그대로 쓰면 세션 고정 공격에 노출됩니다.
- 쿠키 속성을 올바르게 설정합니다.
Secure,HttpOnly,SameSite=Lax이상을 설정하고, 가능하면 쿠키 이름에__Host-접두사를 붙입니다. - 로그아웃하면 서버에서 세션을 폐기합니다. 브라우저의 쿠키만 지우면 이미 탈취된 세션 ID는 계속 쓸 수 있습니다.
같은 문서는 유휴 시간 제한을 위험이 낮은 서비스는 1530분, 중요한 서비스는 25분으로 제시합니다. 활동 여부와 관계없이 적용되는 절대 만료 시간도 따로 두라고 권합니다.
JWT에서 자주 발견되는 문제는 만료 시간이 없거나 지나치게 긴 경우, 그리고 서명 검증 없이 decode만 하는 경우입니다.
// 취약
const token = jwt.sign({ sub: user.id }, SECRET); // exp 없음
const payload = jwt.decode(token); // 서명을 검증하지 않음
// 개선
const token = jwt.sign(
{ sub: user.id, ver: user.tokenVersion },
SECRET,
{ algorithm: "HS256", expiresIn: "15m" }
);
const payload = jwt.verify(token, SECRET, { algorithms: ["HS256"] });
const current = await db.user.getTokenVersion(payload.sub);
if (payload.ver !== current) throw new UnauthorizedError(); // 비밀번호 변경 시 버전을 올려 일괄 폐기
OWASP JSON Web Token Cheat Sheet는 JWT를 세션처럼 쓴다면 무효화할 방법을 따로 마련해야 한다고 지적하고, 폐기된 토큰 목록(deny list)을 예로 듭니다. 매 요청마다 DB를 조회하기 부담스럽다면 다음과 같이 나누는 것도 방법입니다.
- 액세스 토큰은 수명을 짧게 둡니다.
- 리프레시 토큰은 서버에 저장하고, 쓸 때마다 새것으로 교체(rotation)합니다.
- 폐기는 리프레시 토큰 단계에서 확실히 처리합니다.
Next.js처럼 미들웨어에서 인증을 처리한다면 Next.js 보안 가이드도 함께 보시길 권합니다.
소셜 로그인 계정 연결 (12번)
OpenID Connect Core 1.0 5.7절은 사용자를 유일하게 식별할 수 있는 값은 iss와 sub의 조합뿐이라고 명시합니다. email 같은 클레임을 고유 식별자로 써서는 안 된다(MUST NOT)는 규정도 있습니다. 그런데도 소셜 로그인으로 받은 이메일이 기존 계정과 같으면 자동으로 합쳐 버리는 구현이 여전히 많습니다.
// 취약: 이메일만 같으면 기존 계정으로 로그인시킨다
const user = (await db.user.findByEmail(claims.email))
?? (await db.user.create({ email: claims.email }));
return login(user.id);
// 개선
const linked = await db.identity.find({ issuer: claims.iss, subject: claims.sub });
if (linked) return login(linked.userId);
if (claims.email_verified !== true) { // 문자열 "false"나 값 누락은 거부
return res.status(400).json({ error: "인증된 이메일이 필요합니다" });
}
const existing = await db.user.findByEmail(claims.email);
if (existing) {
// 자동 병합하지 않는다. 기존 방식으로 로그인한 뒤 설정 화면에서 직접 연결하게 한다
return res.redirect("/login?notice=link-required");
}
const user = await db.user.createWithIdentity(claims);
return login(user.id);
반대 방향의 문제도 있습니다. 공격자가 피해자의 이메일로 미리 비밀번호 계정을 만들어 두고, 나중에 피해자가 소셜 로그인을 하면 두 계정이 합쳐지는 경우입니다. 이렇게 되면 공격자는 자신이 정한 비밀번호로 계속 그 계정에 로그인할 수 있습니다. 이메일 인증을 마치지 않은 계정은 연결 대상에서 빼야 이 문제를 막을 수 있습니다.
확인 방법: 스테이징 환경에서 이메일 인증을 하지 않은 비밀번호 계정을 만든 뒤, 같은 이메일의 소셜 계정으로 로그인해 보세요. 두 계정이 어떻게 처리되는지 확인합니다.
자주 묻는 질문
12가지를 한 번에 다 고쳐야 하나요?
우선순위를 두면 됩니다. 재설정 토큰 재사용, 인증번호 시도 횟수 제한, 소셜 로그인 자동 연결은 계정 탈취로 바로 이어지므로 먼저 고치세요. 계정 존재 여부 노출은 그다음입니다.
JWT와 세션 중 무엇이 더 안전한가요?
방식 자체보다 만료와 폐기를 제대로 설계했는지가 더 중요합니다. 즉시 로그아웃이나 강제 만료가 꼭 필요한 서비스라면 서버 세션이 구현하기 더 단순합니다.
2단계 인증(MFA)을 도입하면 이 점검은 생략해도 되나요?
아닙니다. MFA는 비밀번호 유출 피해를 줄여 주지만, 재설정 흐름이나 소셜 로그인 연결에 결함이 있으면 MFA를 거치지 않고 계정에 접근할 수 있습니다. 인증 경로 하나하나를 각각 점검해야 합니다.
정리
- 계정 존재 여부는 응답 문구, 상태 코드, 응답 시간 세 군데에서 모두 드러나지 않게 맞춥니다.
- 시도 횟수는 계정 기준으로 제한하고, 특히 인증번호 검증과 메일·SMS 발송 API를 빠뜨리지 않습니다.
- 재설정 토큰은 무작위로 만들고 해시로 저장하며, 한 번만 쓸 수 있고 만료되게 합니다. 비밀번호를 바꾸면 세션과 리프레시 토큰을 모두 폐기합니다.
- 소셜 로그인은
iss와sub조합으로 식별하고, 이메일만으로 자동 연결하지 않습니다. 직접 점검이 어렵다면 무료 분석 신청으로 외부 시각의 점검을 받아볼 수 있습니다.
참고 자료
함께 읽으면 좋은 글
AWS 보안 설정 가이드: 초기 스타트업이 하루에 잠글 5가지
AWS 보안 설정을 루트 계정, IAM, S3, 보안 그룹, 감사 로그 순으로 정리했다. 소규모 팀이 하루 안에 점검하고 고칠 수 있는 명령어와 수정 전후 예시, 판단 기준을 담았다.
취약점 점검 리포트 읽는 법: 위험도 해석부터 조치 우선순위까지
취약점 점검 리포트의 위험도를 서비스 맥락으로 해석하고, 조치 순서 결정, 오탐 확인, 재점검 요청, 수정 완료 근거 정리까지 개발팀의 처리 흐름을 안내합니다.
개인정보 유출 사고 사례: 개인정보위 처분에서 반복된 원인 5가지
개인정보 유출 사고 사례를 개인정보위 처분 기준으로 분석해 반복된 원인 5가지를 정리하고, 개발팀이 바로 쓸 코드 예시와 점검 항목으로 바꿔 줍니다.