이메일 OTP 인증에서 오해하는 것들 (One-Time Password)

Merge SimpsonMerge Simpson
6 min read

✍️ 스터디원의 고민

공부를 쉬지 않는 스터디원들 중 한 분이, 대학에서 다른 사이드 프로젝트를 병행하시면서 꾸준히 공부 현황을 공유해 오셨습니다. 그러다가 이번에 문득 이메일 인증을 언급하셨습니다.

💬 “백엔드 쪽 이메일 인증을 만들어 놔야겠네요. 리서치가 필요할 듯합니다.”

- 텐둥이, the 오랜 friend of Merge Simpson

아, 리서치!

💭 좋은 자료가 농도 높게 존재하는 주제라면 리서치에서 충분히 좋은 정보를 얻을 수 있겠지만, 이메일 인증에 대한 자료는 다소 미비한 게 많다고 느꼈습니다. 많은 글에서 간단하게 OTP를 생성해서 이메일로 전송하는 자체에만 집중한다고 느꼈거든요.

자그마한 인사이트 불어넣기 👀

어쩌면 낮은 농도의 글들 사이에서도 충분히 유익한 아이디어를 도출하실지도 모릅니다만, 적어도 탐구할 가치가 있는 인사이트를 미리 보충하시면 모호한 수준의 리서치에서 그치지 않으실 거라고 생각했습니다. 계기도 생겼겠다, 바쁜 시기지만 블로그에도 한번 새 글을 게시해 보겠습니다!


🤔 OTP, 만들어서 보내기

가장 기본적인 것은, 당연하게도 다른 글들에서 다루는 것처럼, OTP(One-Time Password)를 생성해서 사용자에게 전달하는 행위 그 자체입니다! 이번에는 이메일에 대한 인증이기 때문에, OTP를 이메일로 전달할 겁니다. 이메일 인증을 위한 OTP는 주로 서버에서 생성합니다.

생성

  • OTP의 길이는 최소 6글자로 합니다. NIST(미국 국립표준기술연구소)에서도 이를 권합니다.

  • 이메일 OTP 방식에서는 OTP를 암호학적 난수 생성 함수로 생성합니다. (참고: 반드시 소프트웨어 기반이 아니어도 됩니다.)

전달

  • 메일 서비스를 사용해서 전달합니다. (예를 들어 발신 서버를 구글로 한다면, 우리 서버 → 구글 메일 서버 → 수신 메일 서버 → 사용자 컴퓨터 순으로 이메일이 전달됩니다.)

보존 (대조군)

  • 서버에도 OTP를 보존합니다. (랜덤 생성 시)

  • OTP의 수명을 짧게 하는 것이 일반적입니다.

🎁 OTP 보존 방식 비교 (평문 vs 단방향 암호화)

  • 평문 저장을 한다면 그 이유

    • OTP의 수명이 매우 짧습니다.

    • 데이터베이스 접근 제어가 충분히 잘되어 있다면 유출 가능성이 낮습니다.

    • 6자리 숫자 OTP: 만약 실시간으로 유출되면 단방향 암호화가 충분히 보호해 줄 수 없는 경우의 수입니다.

      솔트와 함께 보존된 단방향 암호화라면 경우의 수가 적어서 임의 대입으로부터 충분한 시간 동안 보호해 줄 수 없습니다. (100만 가지 경우는 암호화 방식과 공격자 컴퓨팅 성능에 따라 수 초 이내에 돌파될 수 있습니다.)

비교: 일반적인 단방향 암호화 시

  • 6자리 정도 짧은 OTP일 때, 데이터베이스 유출로부터 충분히 보호해 주지 않습니다.

  • 서버에서 추가적인 처리로 자원을 낭비합니다.

  • 단방향 암호화를 원한다면 그 이유와 방식

    • 데이터베이스의 접근 제어는 많은 유명 기업으로부터 빈번하게 실수가 발생하는 작업입니다.

    • 따라서 ‘오프라인에서’ 임의로 해석하거나 대입해 볼 수 없는 구조를 지향하는 것이 좋습니다.

방식

  • 시크릿키 또는 페퍼링으로 데이터베이스 유출 시에도 오프라인에서 임의로 해싱을 시도할 수 없도록 합니다.

  • 이렇게 하면 공격자는 해시 값 대조에 앞서 시크릿키 또는 페퍼 값을 먼저 알아내야 합니다.

  • 데이터베이스의 실시간 유출 시에도 시크릿키 또는 페퍼 값이 노출되지 않도록 데이터베이스와 격리하여 관리합니다.

이렇게 OTP를 생성해서 전달하는 과정은 간단한데요! 서버에도 평문 저장이 일반적이어서 대체로 구현도 간단합니다.

문제는 OTP가 사용자의 이메일로 안전하게 전달될 것이라는 가정을 쉽게 한다는 거죠. 하지만 이 과정에서 놓치지 말아야 할 것은 OTP가 생성된 후 여러 메일 서비스를 거쳐 수신자에게 도달할 때까지, 우리가 OTP를 완전히 은닉할 수 없는 점입니다.

즉, “OTP 발급 시 경유하는 메일 서비스들을 신뢰할 수 있어야 하지만, 남의 서비스라서 보장되는 일은 아닙니다.”


🕊 👀 통신 구간 신뢰도

서버에서 생성한 OTP를 사용자에게 전달할 때는, 반드시 통신 구간을 거칩니다. 특히 이메일 인증을 위한 OTP 전달은, 여러 메일 서비스를 거치게 되는데요. 이때 경유하는 메일 서비스들을 신뢰할 수 있어야 하지만, 그 서비스의 의도를 신뢰하는 것도, 그 메일 서비스의 통신 구간 암호화 수준을 신뢰하는 것도 참 낙천적인 일입니다.

여러분의 서버 → 통신구간 A → 구글 → 통신구간 B → 수신자 메일 서버 → 통신구간 C → 수신자 클라이언트

우리가 서버에서 은닉할 값을 생성해도, 메일을 통한 OTP 전달 프로세스에서 결국 다음과 같은 유출 위험을 맞이하게 됩니다.

  • 은닉할 값이 결국 경유하는 메일 서비스들에 공개됩니다. 이 서비스들이 악의가 없다고 신뢰해야 합니다(?).

  • 메일 서비스와 메일 서비스 사이의 통신 구간 보안도 그들의 몫입니다. 즉, 우리가 통제할 수 없습니다.

  • 수신 메일 서비스로부터 사용자가 메일을 받을 때의 통신 구간 보안도 우리가 통제할 수 없습니다.

어디든 네트워크상에서 ‘중간자(MITM: Man in The Middle)’라는 위협이 도사리고 있죠. 이래서 중요한 값을 은닉하는 통신은 모두 암호화가 필수인데 말입니다.

예를 들어, 위 그림에서 통신구간 B와 C는 남들의 몫입니다. 어떻게 노출이 될지 모르기 때문에 중간자 공격으로부터 안전하다 할 순 없을 거예요. 게다가 우리가 아무리 발신 메일 서버를 신뢰해도, 수신 메일 서버는 신뢰하기 어려운 서비스일 수도 있을 겁니다. 아, 사용자의 메일 우회 설정이나, 수신 메일 서버의 알 수 없는 여러 중간 전달 과정으로 더 많은 메일 서버를 거칠 수도 있으니까, 더 많은 B와 C가 있을 수 있습니다. ✌️

위 그림에 대해서 스크린리더가 읽어 줄 텍스트 대신, 아래에 구간별로 읽어 주는 목록으로 대신합니다.

  • 여러분의 서버 → (통신구간 A) → 구글

  • 구글 → (통신구간 B) → 수신자 메일 서버

  • 수신자 메일 서버 → (통신구간 C) → 수신자 클라이언트

🙏 우리가 신뢰하는 통신 구간에게 “부탁 하나 하기”

그래서 어떻게든 우리가 신뢰할 수 있는 수단을 활용해서 보조하면 더 좋을 것 같습니다!

예를 들어 이때는, ‘사용자가 우리 사이트에 접속해 있는 시점’을 활용하는 거죠.

사용자가 우리 사이트에 접속해서 요청을 주고 받는 건, 분명 우리가 신뢰할 수 있는 통신 구간이잖아요? 이 통신 구간을 활용해서 보조해 보려고 합니다.

우리 서버 → (신뢰할 수 있는 통신 구간) → 수신자 클라이언트

사용자가 보낸 요청: “OTP를 메일로 보내 주세요.”에 응답할 때 토큰 심기

우리가 이메일 인증용 OTP를 발송하는 것은 잘 생각해 보면 ‘사용자 상호작용’이 있을 때입니다. 그냥 보내면 그건 스팸이죠. 명절 선물로 스팸을 주고 받는 것도 한국식 문화라고 합니다. 😅

사용자의 요청은 다음과 같습니다.

“저 회원가입 할 건데, 이게 제 메일이거든요. 이 메일이 제 소유라는 걸 확인시키기 위한 OTP 값을 제 메일로 보내 주세요.

(예시) Request Body or Parameter:

{“email”: “their.email@example.com“}

여기서 사용자에게 ‘수명’이 있는 특정 토큰을 발급해 줍니다. 전달 방식은 다양하지만, 예시니까 바디로 응답할까요?

서버 “네, 메일로 OTP 보내는 중인데 이거 좀 갖고 계시다가, OTP 확인할 때 같이 제시해 주실래요? 임시 신분증이에요.“

(예시) Response Body:

{“email_otp_verification_token“: “사용자(사람)가 직접 외우는 게 아니기 때문에 충분히 긴 토큰이 좋습니다.“}

이후 단계는 다음과 같습니다.

  • 이처럼 사용자에게 토큰을 발급해 줄 때, 서버에서도 이 충분히 긴 길이의 토큰을 안전하게 보관합니다. (stateful 관리 시)

  • 클라이언트에서도(≈ 프론트엔드에서도) 토큰을 잘 저장해 두었다가 나중에 OTP와 토큰을 함께 서버로 보냅니다.

생성과 보존

  • 이 토큰은 프론트엔드 쪽 스크립트로 관리되는 토큰이기 때문에 충분히 긴 토큰으로 생성합니다.
    (여섯 자리 OTP는 사용자가 직접 다루어야 하기 때문에 짧은 길이를 선호하는 것과 대조적입니다.)

  • 서버 측에 저장할 때, 단방향 암호화를 하는 의미가 전혀 없지 않기 때문에(충분히 많은 경우의 수) 기본적인 습관을 해싱으로 하는 것이 좋습니다. (stateful 관리 시)

수명은 OTP보다 짧지 않아야 하고, 마찬가지로 수명이 짧아서 단방향 암호화 여부가 필수적인 것은 아닙니다.

주의사항

  • 토큰이라고 해서 반드시 JWT로 생성하지 않아도 됩니다. 👀 특히 JWT의 페이로드(본문)에 OTP를 심으면, 이메일 소유를 확인하는 의미가 전혀 없겠죠. ⚠️

  • 예시는 간단하고 안전하게 stateful한 관리를 하는 겁니다. 이 방식을 권하는 이유는 OTP 재발급, 과도한 시도 횟수 등 여러 이유로 상태를 서버에서 제어할 때가 있을 수 있기 때문입니다.


이렇게 생성한 토큰은, HTTPS 등 우리가 신뢰해도 되는 안전한 통신 환경에서 전달하고, 이후 이메일 OTP 인증 시 토큰을 함께 보내도록 요구할 수 있습니다.

이로써 한층 안전한 이메일 인증 환경을 구축할 수 있습니다.

참고: 이메일 인증 시퀀스 다이어그램

---
config:
  theme: forest
  look: handDrawn
---
sequenceDiagram
  participant Client Email Server
  box rgba(150, 250, 100, 0.5) 통제 가능한 통신 구간
    participant Client
    participant  Server
  end
  activate Client
  Client ->> Server: 인증 메일 요청
  activate Server
  Server ->> Sender Email Server : OTP 발행
  Server ->> Client: Token 발행
  deactivate Server
  Sender Email Server -->> Client Email Server: OTP가 담긴 메일 전달
  Client Email Server ->> Client: OTP 확인
  Client ->> Server: 인증 (OTP, Token)
  deactivate Client

그럼 오늘도 다들 모쪼록 착한 개발 하세요. 🐳

30
Subscribe to my newsletter

Read articles from Merge Simpson directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Merge Simpson
Merge Simpson

Hello, I am Korean. Welcome, visitor. You are very cool. 안녕하세요, 저는 한국어입니다. 방문자여 환영한다. 당신은 매우 시원해.