회원가입과 로그인에 아이디와 비밀번호만 사용하는 시스템은 비밀번호가 유출되는 순간 계정이 탈취될 수 있다. 비밀번호가 안전하게 해싱되어 있더라도 사용자가 피싱 사이트에 입력하거나 다른 서비스에서 재사용한 비밀번호가 유출되면 인증 체계 자체가 무력화될 수 있다.
이를 보완하기 위해 사용하는 것이 추가 인증(Multi-Factor Authentication, MFA) 이다. 추가 인증은 비밀번호 외에 별도의 인증 수단을 요구하여 계정 접근에 필요한 보안 수준을 높인다.
추가 인증 시스템을 설계할 때는 다음 항목을 고려해야 한다.
- 2단계 인증(2FA)
- 인증 코드 생성 및 만료 시간 관리
- 인증 시도 횟수 제한
- 계정 탈취 탐지 및 대응
핵심은 인증 코드를 발급하는 것만이 아니다. 코드를 안전하게 생성하고, 검증하고, 소비하고, 실패 상황을 통제하는 전체 과정을 설계해야 한다.
2단계 인증(2FA)
2단계 인증이란?
2단계 인증(Two-Factor Authentication, 2FA)은 로그인 과정에서 서로 다른 두 가지 인증 요소를 요구하는 방식이다.
인증 요소는 일반적으로 다음 세 가지로 구분한다.
| 인증 요소 | 설명 | 예시 |
|---|---|---|
| 지식 기반 | 사용자가 알고 있는 정보 | 비밀번호, PIN |
| 소유 기반 | 사용자가 소유한 기기나 수단 | 인증 앱, 보안 키 |
| 생체 기반 | 사용자의 생체적 특성 | 지문, 얼굴 인식 |
서로 다른 요소를 두 가지 이상 사용해야 진정한 의미의 다중 요소 인증이 된다.
예를 들어 비밀번호와 별도의 비밀번호를 요구하는 것은 인증 정보가 두 개일 뿐, 반드시 2FA가 되는 것은 아니다. 반면 비밀번호와 인증 앱에서 생성한 일회용 코드를 함께 사용하면 서로 다른 인증 요소를 결합할 수 있다.
2FA 방식의 종류
이메일 인증 코드
사용자가 로그인하면 등록된 이메일 주소로 인증 코드를 전송한다.
- 구현이 비교적 간단하다.
- 이메일 계정이 탈취되면 추가 인증도 무력화될 수 있다.
- 이메일은 별도의 인증 요소로 분류할 때 주의가 필요하다. 이메일 계정의 접근 수단이 같은 비밀번호에 의존한다면 인증 독립성이 약해진다.
SMS 인증 코드
휴대전화 번호로 일회용 인증 코드를 전송한다.
- 사용자에게 익숙하고 사용하기 쉽다.
- SIM 스와핑, 문자 가로채기, 전화번호 탈취 등의 위험이 있다.
- 문자 발송 비용과 발송량 제한을 고려해야 한다.
인증 앱 기반 TOTP
Google Authenticator와 같은 인증 앱이 시간 기반 일회용 비밀번호(Time-based One-Time Password)를 생성한다.
- 별도의 문자 발송 비용이 없다.
- 네트워크 연결 없이도 코드를 생성할 수 있다.
- 서버와 인증 앱이 공유하는 비밀키를 안전하게 관리해야 한다.
보안 키 및 패스키
FIDO2/WebAuthn 기반 인증은 피싱에 강한 인증 수단을 제공한다. 특히 패스키는 비밀번호 의존도를 줄이면서 사용자 기기의 인증 기능을 활용할 수 있다.
보안 수준이 중요한 서비스라면 SMS나 이메일 코드만으로 모든 인증을 해결하기보다 인증 앱, 보안 키, 패스키 등의 도입을 검토하는 것이 좋다.
2FA의 기본 동작 흐름
로그인 과정에서 추가 인증이 필요한 경우, 비밀번호 검증이 성공했다고 해서 바로 로그인 세션을 발급해서는 안 된다.
클라이언트
|
| POST /auth/login
| 이메일 + 비밀번호
v
인증 서버
|
| 비밀번호 검증 성공
v
추가 인증 필요 여부 확인
|
| 인증 절차 시작
v
인증 코드 발급 또는 TOTP 검증 준비
|
| POST /auth/2fa/verify
| 인증 코드 제출
v
인증 서버
|
| 코드 검증 및 사용 처리
v
최종 로그인 세션 또는 토큰 발급
이 과정에서 중요한 점은 추가 인증이 완료되기 전에는 일반적인 서비스 접근 권한을 부여하지 않는 것이다.
첫 번째 인증에 성공한 사용자는 아직 완전히 인증된 사용자가 아니다. 따라서 제한된 인증 상태를 나타내는 임시 토큰이나 서버 측 인증 트랜잭션을 사용할 수 있다.
임시 토큰은 일반 Access Token과 구분하고, 최종 인증 API 외에는 사용할 수 없도록 제한해야 한다.
인증 코드 생성 및 만료 시간
인증 코드는 어떻게 생성해야 하는가?
인증 코드는 추측하기 어려워야 하며, 공격자가 동일한 코드를 반복해서 사용할 수 없어야 한다.
인증 코드는 보안 목적에 적합한 암호학적 난수 생성기(CSPRNG)를 사용해 생성한다.
Node.js에서는 crypto 모듈의 randomInt()를 사용할 수 있다.
import { randomInt } from "node:crypto";
function generateVerificationCode(): string {
return randomInt(0, 1_000_000)
.toString()
.padStart(6, "0");
}
const code = generateVerificationCode();
console.log(code); // 예: 042817
이 코드는 000000부터 999999까지의 여섯 자리 숫자를 생성한다.
여섯 자리 코드는 총 1,000,000가지 경우의 수를 가지므로, 공격자가 반복적으로 시도할 수 있다면 무차별 대입 공격에 취약해질 수 있다. 따라서 코드의 길이뿐 아니라 만료 시간과 시도 횟수 제한을 함께 설계해야 한다.
인증 코드 생성 시 Math.random()을 사용하지 않는 것이 좋다. 일반적인 난수 생성 목적에는 사용할 수 있지만, 인증 정보처럼 예측 가능성이 보안에 영향을 주는 영역에는 암호학적 난수 생성기를 사용해야 한다.
인증 코드에 만료 시간이 필요한 이유
인증 코드가 영구적으로 유효하다면 과거에 발급된 코드가 유출되었을 때도 계정 접근에 사용될 수 있다.
따라서 인증 코드에는 유효 기간을 설정해야 한다.
예를 들어 이메일 인증 코드를 발급한 시점부터 5분 동안만 유효하도록 설정할 수 있다.
인증 코드 데이터는 다음과 같이 관리할 수 있다.
interface VerificationCode {
userId: string;
codeHash: string;
expiresAt: number;
attempts: number;
consumed: boolean;
}
각 필드의 역할은 다음과 같다.
userId: 인증 대상 사용자codeHash: 인증 코드의 검증용 해시expiresAt: 만료 시각attempts: 인증 시도 횟수consumed: 이미 사용되었는지 여부
실제 운영 환경에서는 이 데이터를 Redis와 같은 저장소에 보관할 수 있다. 만료 시각은 애플리케이션에서 검증하고, Redis의 TTL을 설정하여 만료된 데이터를 자동으로 정리할 수도 있다.
인증 코드를 평문으로 저장하지 않기
인증 코드가 데이터베이스나 Redis에 평문으로 저장되어 있다면 저장소가 유출되었을 때 공격자가 코드를 그대로 사용할 수 있다.
따라서 인증 코드 대신 검증용 해시를 저장하는 방식을 고려할 수 있다.
import { createHash } from "node:crypto";
function hashVerificationCode(code: string): string {
return createHash("sha256")
.update(code)
.digest("hex");
}
코드를 검증할 때는 사용자가 입력한 코드의 해시를 계산한 뒤 저장된 해시와 비교한다.
다만 여섯 자리 숫자는 경우의 수가 매우 적기 때문에 일반적인 SHA-256 해시만으로는 오프라인 무차별 대입 공격을 충분히 방어할 수 없다. 저장소 유출까지 고려한다면 서버 비밀키를 사용하는 HMAC 기반 검증값이나 별도의 안전한 키 관리 방식을 검토해야 한다.
인증 코드 검증값을 생성할 때 사용하는 비밀키는 코드와 동일한 저장소에 함께 보관하지 않는 것이 좋다.
인증 코드는 한 번만 사용할 수 있어야 한다
인증 코드가 올바르더라도 이미 사용된 코드라면 인증에 성공해서는 안 된다.
예를 들어 인증 코드를 검증한 후에도 해당 코드가 저장소에 남아 있다면 공격자가 같은 코드를 재전송할 수 있다.
따라서 인증 코드는 다음 조건을 만족해야 한다.
- 만료되지 않았을 것
- 인증 대상 사용자와 일치할 것
- 검증에 성공할 것
- 이전에 사용되지 않았을 것
- 허용된 시도 횟수를 초과하지 않았을 것
검증에 성공했다면 해당 코드를 즉시 소비 처리해야 한다. 동시에 여러 요청이 들어오는 경우를 고려하여 검증과 소비 처리가 원자적으로 이루어지도록 설계해야 한다.
Redis에서는 Lua 스크립트나 원자적 명령을 활용할 수 있고, 관계형 데이터베이스에서는 트랜잭션과 조건부 업데이트를 활용할 수 있다.
인증 시도 제한
왜 시도 횟수를 제한해야 하는가?
여섯 자리 인증 코드는 경우의 수가 제한되어 있다. 공격자가 초당 수백 번씩 코드를 입력할 수 있다면 유효한 코드를 추측할 가능성이 커진다.
이를 방어하려면 인증 시도 횟수를 제한해야 한다.
예를 들어 다음과 같은 정책을 설정할 수 있다.
| 제한 대상 | 예시 정책 |
|---|---|
| 인증 코드별 | 최대 5회 실패 |
| 사용자 계정별 | 일정 시간 동안 실패 횟수 제한 |
| IP 주소별 | 일정 시간 동안 요청 횟수 제한 |
| 코드 발급 API | 재발급 간격 및 시간당 발급량 제한 |
| 로그인 API | 비밀번호 실패 횟수 및 요청 속도 제한 |
위 수치는 예시이며, 실제 서비스의 위험도와 사용 패턴에 따라 조정해야 한다.
인증 코드별 시도 제한
인증 코드를 발급할 때 시도 횟수를 0으로 초기화한다.
사용자가 잘못된 코드를 입력할 때마다 횟수를 증가시키고, 제한을 초과하면 해당 코드를 더 이상 사용할 수 없도록 처리한다.
const MAX_ATTEMPTS = 5;
function canAttemptVerification(
attempts: number
): boolean {
return attempts < MAX_ATTEMPTS;
}
이 코드는 제한 여부를 판단하는 기본 로직이다. 실제 서버에서는 여러 요청이 동시에 들어와도 횟수 제한을 우회할 수 없도록 저장소에서 원자적으로 횟수를 확인하고 증가시켜야 한다.
실패 횟수는 인증 코드가 재발급되었다는 이유만으로 무제한 초기화되어서는 안 된다. 그렇지 않으면 공격자가 코드를 계속 재발급받아 시도 제한을 우회할 수 있다.
Rate Limiting과 Brute-force 방어
인증 코드 검증 API에는 요청 속도 제한을 적용해야 한다.
예를 들어 다음과 같은 API가 있다고 가정하자.
POST /auth/2fa/verify
Content-Type: application/json
{
"challengeId": "challenge-123",
"code": "042817"
}
서버는 인증 코드의 정합성을 확인하는 동시에 다음 사항을 검사해야 한다.
- 해당 챌린지가 존재하는가?
- 챌린지가 만료되지 않았는가?
- 이미 완료되거나 폐기된 인증 절차가 아닌가?
- 사용자가 허용된 시도 횟수를 초과했는가?
- 계정 및 IP별 요청 제한을 초과했는가?
IP 주소만으로 제한하면 공격자가 IP를 변경하여 우회할 수 있다. 반대로 계정만으로 제한하면 공격자가 특정 사용자의 계정을 반복적으로 공격하여 정상 사용자의 로그인을 방해할 수 있다.
따라서 IP, 계정, 인증 챌린지 등 여러 기준을 함께 적용하는 것이 좋다.
또한 프록시나 로드밸런서 뒤에서 운영한다면 클라이언트 IP를 신뢰할 수 있는 프록시 설정을 통해 정확하게 판별해야 한다. 임의의 X-Forwarded-For 헤더를 그대로 신뢰해서는 안 된다.
인증 코드 재발급 제한
인증 코드 발급 API도 공격 대상이다.
공격자가 특정 이메일 주소로 인증 코드를 반복 발송하면 문자 발송 비용이 증가하거나 사용자에게 불필요한 메시지가 대량으로 전송될 수 있다.
이를 방지하려면 다음 정책을 적용할 수 있다.
- 코드 재발급 사이에 일정한 대기 시간 설정
- 사용자 및 IP별 시간당 발급 횟수 제한
- 기존 코드 재발급 시 이전 코드 무효화
- 의심스러운 발급 요청에 대한 추가 검증
- 이메일 및 SMS 발송 비용 모니터링
특히 코드 재발급 시 이전 코드를 무효화해야 여러 코드가 동시에 유효한 상태가 되는 문제를 줄일 수 있다.
계정 탈취 탐지 및 대응
계정 탈취란?
계정 탈취(Account Takeover, ATO)는 공격자가 정상 사용자의 인증 정보를 획득하거나 인증 절차를 우회하여 계정에 접근하는 상황이다.
주요 원인은 다음과 같다.
- 유출된 비밀번호의 재사용
- 피싱 사이트를 통한 인증 정보 탈취
- 인증 코드 무차별 대입 공격
- 세션 쿠키 또는 토큰 탈취
- 이메일 계정 탈취
- 비밀번호 재설정 절차의 취약점
- 사회공학적 공격을 통한 인증 수단 변경
계정 탈취 대응은 로그인 성공 여부만 확인하는 방식으로 해결할 수 없다. 인증 과정, 세션 관리, 계정 설정 변경, 비밀번호 재설정 등 계정의 중요한 작업을 종합적으로 보호해야 한다.
의심스러운 로그인 탐지
서버는 로그인 및 인증 이벤트를 기록하고 비정상적인 패턴을 탐지할 수 있어야 한다.
예를 들어 다음 상황은 추가 검증이 필요한 신호가 될 수 있다.
- 짧은 시간 동안 로그인에 반복적으로 실패함
- 새로운 기기에서 로그인함
- 평소와 다른 위치나 네트워크에서 접근함
- 여러 계정에 대해 동일한 IP에서 로그인 시도가 발생함
- 인증 수단 변경 직후 민감한 작업이 수행됨
- 짧은 시간에 여러 세션이 생성됨
다만 새로운 IP나 기기에서 로그인했다는 사실만으로 계정 탈취라고 단정해서는 안 된다. 모바일 네트워크나 VPN 사용으로도 IP 주소가 달라질 수 있다.
따라서 여러 신호를 조합하여 위험도를 평가하고, 위험 수준에 따라 추가 인증을 요구하는 방식이 적절하다.
위험 기반 인증
모든 로그인 요청에 동일한 인증 절차를 적용하는 대신, 위험도에 따라 인증 수준을 조절할 수 있다.
로그인 요청
|
v
기본 인증 정보 검증
|
v
위험 신호 분석
|
+-- 낮은 위험 --> 정상 인증 절차 진행
|
+-- 중간 위험 --> 추가 인증 요구
|
+-- 높은 위험 --> 로그인 차단 또는 계정 보호 절차
이 방식은 사용자 편의성과 보안 수준 사이의 균형을 맞추는 데 유용하다.
단, 공격자가 위험도 판단 기준을 쉽게 파악하여 우회하지 못하도록 세부 규칙과 임계값을 공개적으로 노출하지 않는 것이 좋다.
계정 탈취 발생 시 대응
계정 탈취가 의심되거나 확인되었다면 추가 피해를 막는 조치가 필요하다.
활성 세션 폐기
공격자가 이미 발급받은 Access Token이나 세션 쿠키를 사용하고 있을 수 있다. 비밀번호만 변경해도 기존 세션이 자동으로 무효화되는 것은 아니다.
서버 측 세션을 폐기하거나 토큰의 무효화 정책을 적용해야 한다. 자체 포함형 JWT를 사용하는 경우에는 토큰 만료, 세션 버전, 폐기 목록 등 서비스 구조에 맞는 대응 방식을 설계해야 한다.
Refresh Token 폐기
Refresh Token을 탈취당했다면 공격자가 새로운 Access Token을 발급받을 수 있다. 따라서 의심스러운 세션에 연결된 Refresh Token을 폐기해야 한다.
Refresh Token Rotation을 적용하는 시스템이라면 이미 사용한 Refresh Token이 다시 제출되는 상황을 탐지하고, 해당 토큰 계열을 폐기하는 정책을 고려할 수 있다.
비밀번호 재설정
사용자에게 안전한 비밀번호 재설정 절차를 제공한다. 재설정 링크나 토큰에는 충분한 무작위성, 만료 시간, 일회성 사용 정책을 적용해야 한다.
인증 수단 복구
공격자가 이메일 주소, 휴대전화 번호, 인증 앱 등을 변경했다면 정당한 사용자가 계정을 복구할 수 있는 절차가 필요하다.
복구 과정은 공격자가 탈취한 계정만으로 새로운 인증 수단을 등록할 수 없도록 설계해야 한다.
사용자 알림
새로운 기기 로그인, 비밀번호 변경, 이메일 변경, 인증 수단 변경, 모든 세션 로그아웃 등의 중요한 이벤트가 발생하면 사용자에게 알림을 제공할 수 있다.
알림에는 사용자가 직접 수행하지 않은 작업을 신고하거나 계정을 보호할 수 있는 방법도 포함하는 것이 좋다.
민감한 작업에는 재인증 적용
로그인할 때 인증을 한 번 완료했다고 해서 모든 작업을 무조건 허용해서는 안 된다.
다음과 같은 작업에는 비밀번호 재입력이나 추가 인증을 요구할 수 있다.
- 비밀번호 변경
- 이메일 주소 변경
- 휴대전화 번호 변경
- 2FA 활성화 및 비활성화
- 결제 수단 변경
- 중요한 개인정보 변경
- 계정 삭제
이러한 작업은 계정 탈취 이후 공격자가 계정 소유권을 영구적으로 확보하는 것을 어렵게 만든다.
Node.js와 Redis를 활용한 인증 코드 관리 예시
다음은 Redis를 사용해 인증 코드의 저장과 만료를 관리하는 기본적인 구조다.
실제 운영 코드에서는 인증 코드의 안전한 발급과 전달, 계정별 시도 제한, 원자적 검증, 인증 완료 처리까지 함께 구현해야 한다.
인증 코드 저장
import { randomInt, createHmac } from "node:crypto";
import type { RedisClientType } from "redis";
const CODE_TTL_SECONDS = 300;
function generateCode(): string {
return randomInt(0, 1_000_000)
.toString()
.padStart(6, "0");
}
function createCodeDigest(
userId: string,
code: string,
secret: string
): string {
return createHmac("sha256", secret)
.update(`${userId}:${code}`)
.digest("hex");
}
async function issueVerificationCode(
redis: RedisClientType,
userId: string,
secret: string
): Promise<string> {
const code = generateCode();
const digest = createCodeDigest(userId, code, secret);
const key = `auth:2fa:${userId}`;
await redis.set(
key,
JSON.stringify({
digest,
attempts: 0,
}),
{
EX: CODE_TTL_SECONDS,
}
);
return code;
}
이 예시는 인증 코드의 검증값을 Redis에 저장하고 5분의 TTL을 설정한다.
secret은 환경변수나 비밀 관리 시스템에서 가져와야 하며, 인증 코드와 함께 저장해서는 안 된다. 또한 userId를 그대로 키로 사용하는 방식은 기본적인 예시다. 실제 시스템에서는 동시 로그인, 여러 기기, 재발급, 사용자별 시도 제한 등을 고려해 인증 챌린지 ID를 별도로 사용하는 것이 좋다.
인증 코드 발급 함수는 코드를 반환하지만, 실제 서비스에서는 반환값을 클라이언트에 직접 노출하지 않아야 한다. 검증 코드는 신뢰할 수 있는 이메일 또는 SMS 전달 시스템을 통해 사용자에게 전달해야 한다.
인증 코드 검증
import { timingSafeEqual } from "node:crypto";
const MAX_ATTEMPTS = 5;
function safeDigestEqual(
expected: string,
actual: string
): boolean {
const a = Buffer.from(expected, "hex");
const b = Buffer.from(actual, "hex");
return (
a.length === b.length &&
timingSafeEqual(a, b)
);
}
async function verifyCode(
redis: RedisClientType,
userId: string,
inputCode: string,
secret: string
): Promise<boolean> {
if (!/^\d{6}$/.test(inputCode)) {
return false;
}
const key = `auth:2fa:${userId}`;
const value = await redis.get(key);
if (!value) {
return false;
}
const record = JSON.parse(value) as {
digest: string;
attempts: number;
};
if (record.attempts >= MAX_ATTEMPTS) {
await redis.del(key);
return false;
}
const actualDigest = createCodeDigest(
userId,
inputCode,
secret
);
if (
!safeDigestEqual(
record.digest,
actualDigest
)
) {
record.attempts += 1;
if (record.attempts >= MAX_ATTEMPTS) {
await redis.del(key);
} else {
const ttl = await redis.ttl(key);
if (ttl > 0) {
await redis.set(
key,
JSON.stringify(record),
{ EX: ttl }
);
}
}
return false;
}
await redis.del(key);
return true;
}
위 코드는 흐름을 이해하기 위한 예시다. 이 구현을 그대로 운영 환경에 적용해서는 안 된다.
특히 다음과 같은 보완이 필요하다.
- Redis의 조회, 시도 횟수 증가, 검증, 삭제가 여러 요청에서 원자적으로 처리되도록 Lua 스크립트나 적절한 트랜잭션을 사용한다.
- 잘못된 코드를 동시에 제출해도 허용 횟수를 초과할 수 없도록 한다.
- 인증 코드 검증 성공과 최종 로그인 권한 발급 사이의 상태 전이를 안전하게 처리한다.
- 사용자별 및 IP별 요청 제한을 별도로 적용한다.
- 만료된 코드, 재발급된 코드, 이미 사용된 코드가 다시 승인되지 않도록 한다.
- 인증 코드와 인증 토큰을 로그에 기록하지 않는다.
또한 위 예시에서는 성공한 인증 코드의 레코드를 삭제하지만, 인증 완료 상태를 별도의 서버 측 챌린지 상태로 관리하는 설계도 가능하다. 핵심은 성공 처리가 원자적으로 이루어지고, 같은 코드를 이용해 두 번 인증을 완료할 수 없어야 한다는 점이다.
운영 환경에서 추가로 고려할 사항
인증 상태를 명확히 분리하기
인증 시스템에는 서로 다른 상태가 존재한다.
- 미인증
- 비밀번호 인증 완료
- 추가 인증 대기
- 추가 인증 완료
- 인증 실패 또는 잠금
- 인증 만료 또는 폐기
이 상태들을 명확하게 분리하면 추가 인증이 완료되지 않은 사용자가 일반 API를 호출하는 문제를 방지할 수 있다.
오류 메시지와 로그 관리
인증 실패 응답은 공격자에게 불필요한 정보를 제공하지 않아야 한다.
예를 들어 사용자 계정의 존재 여부, 인증 코드의 일부, 내부 검증 상태를 과도하게 노출하지 않는 것이 좋다.
로그에는 다음과 같은 이벤트를 기록할 수 있다.
- 인증 코드 발급
- 인증 코드 검증 성공 및 실패
- 인증 시도 제한 초과
- 추가 인증 완료
- 인증 수단 변경
- 세션 폐기
- 의심스러운 로그인 탐지
로그에는 비밀번호, 인증 코드, Access Token, Refresh Token, 세션 쿠키, 비밀키 등의 민감한 정보를 남기지 않아야 한다.
인증 복구 절차
사용자가 휴대전화를 분실하거나 인증 앱에 접근할 수 없게 되었을 때를 대비해야 한다.
복구 코드, 별도의 인증 수단, 강화된 본인 확인 절차 등을 제공할 수 있다. 복구 코드는 일반 인증 코드와 마찬가지로 충분히 무작위적이어야 하며, 일회성 사용과 안전한 저장 정책을 적용해야 한다.
2FA를 비활성화하는 과정 역시 중요한 보안 작업이다. 이미 탈취된 세션만으로 인증 수단을 제거할 수 없도록 최근 인증 여부와 추가 확인을 요구해야 한다.