본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Backend

텔레그램 미니앱 인증 흐름

작성 2026. 10. 13.추가
목차7개 항목

텔레그램 미니앱(Telegram Mini App) 은 텔레그램 안에서 열리는 웹앱이다. 별도 로그인 화면 없이, 텔레그램이 앱을 열 때 넘겨주는 initData(사용자 정보 + 서명)로 사용자를 인증한다. 클라이언트가 받은 사용자 정보를 그대로 믿지 않고, 서버가 봇 토큰으로 서명을 검증한 뒤에만 신뢰한다. 코드 예시는 Next.js 기준이지만 흐름은 어떤 서버에서나 같다.

전체 흐름

flowchart TB
  tg["텔레그램<br/>미니앱 실행 → initData(서명 포함) 전달"] --> cl["클라이언트<br/>SDK 초기화 → initData 원본을 서버로 전송"]
  cl --> sv["서버<br/>initData 파싱 → auth_date 만료 확인 → HMAC 해시 계산·비교"]
  sv -->|"성공"| ok["사용자 생성·조회 → 자체 세션·JWT 발급"]
  sv -->|"실패"| ng["403 Forbidden"]
  ok --> cl2["클라이언트<br/>이후 요청은 세션 쿠키·토큰으로 인증"]

1. 클라이언트: initData 얻어서 보내기

const WebApp = (await import('@twa-dev/sdk')).default // 브라우저에서만 로드되도록 동적 import
WebApp.ready() // 로딩 완료를 텔레그램에 알림
 
const initData = WebApp.initData                   // 서명된 원본 문자열 → 서버 검증용
const user = WebApp.initDataUnsafe.user            // 파싱된 값 → 화면 표시용으로만
 
const response = await fetch('/api/auth/telegram', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ telegramInitData: initData }),
})
if (!response.ok) throw new Error('Telegram auth failed')
  • initDataUnsafe는 이름 그대로 검증되지 않은 값이다. 화면에 이름을 보여 주는 정도로만 쓰고, 권한 판단에는 쓰지 않는다.
  • 서버에는 반드시 원본 문자열 initData를 보낸다.

2. 서버: initData 검증

텔레그램 공식 문서의 검증 절차:

  1. initData를 쿼리 스트링으로 파싱하고 hash 값을 꺼낸다.
  2. hash를 제외한 나머지 key=value 쌍을 key 기준으로 정렬해 \n으로 이어 붙인다(data-check-string).
  3. secret_key = HMAC_SHA256(key="WebAppData", message=봇 토큰)
  4. HMAC_SHA256(key=secret_key, message=data-check-string)의 hex 값이 hash와 같으면 텔레그램이 보낸 진짜 데이터다.
  5. auth_date가 너무 오래되었으면 재사용(리플레이)을 막기 위해 거절한다.
import crypto from 'crypto'
 
const MAX_AGE_SECONDS = 60 * 60 * 3 // 3시간 (서비스에 맞게 조정)
 
export function validateTelegramInitData(telegramInitData: string, botToken: string) {
  const params = new URLSearchParams(telegramInitData)
  const hash = params.get('hash')
  if (!hash) return { ok: false, message: 'Missing hash' } as const
 
  const authDate = Number(params.get('auth_date'))
  if (!authDate || Date.now() / 1000 - authDate > MAX_AGE_SECONDS) {
    return { ok: false, message: 'Telegram data is too old' } as const
  }
 
  params.delete('hash') // 서명 대상에서 hash 자체는 빼야 한다
  const dataCheckString = [...params.entries()]
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([key, value]) => `${key}=${value}`)
    .join('\n')
 
  const secretKey = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest()
  const calculated = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
 
  const valid =
    calculated.length === hash.length &&
    crypto.timingSafeEqual(Buffer.from(calculated), Buffer.from(hash))
  if (!valid) return { ok: false, message: 'Hash validation failed' } as const
 
  const user = JSON.parse(params.get('user') ?? '{}')
  return { ok: true, user } as const
}
  • hash까지 포함해 data-check-string을 만들면 해시가 절대 일치하지 않는다. hash는 반드시 뺀다.
  • 해시 비교는 === 대신 crypto.timingSafeEqual로 해 응답 시간 차이로 값을 추측하는 타이밍 공격을 막는다.
  • 봇 토큰이 설정되지 않았으면 검증하지 말고 서버 설정 오류로 처리한다.
  • initData에는 hash 외에 signature 필드도 들어 있다. 위 HMAC 검증에서는 hash만 빼고 signature를 포함한 나머지 필드를 그대로 쓴다.

봇 토큰 없이 검증하기 (서드파티용)

봇 토큰을 공유할 수 없는 외부 서비스는 signature(Ed25519 서명, base64url)를 텔레그램이 공개한 공개키로 검증할 수 있다. 이때 data-check-string은 첫 줄에 <bot_id>:WebAppData를 두고, 그 아래에 hash와 signature를 둘 다 뺀 필드를 정렬해 이어 붙인다. auth_date 만료 확인은 똑같이 한다. (Telegram Mini Apps: Validating data)

3. API 라우트: 검증 후 세션 발급

// app/api/auth/telegram/route.ts
import { NextResponse } from 'next/server'
 
export async function POST(req: Request) {
  const { telegramInitData } = await req.json()
  const result = validateTelegramInitData(telegramInitData, process.env.BOT_TOKEN!)
  if (!result.ok) {
    return NextResponse.json({ error: 'Invalid Telegram data' }, { status: 403 })
  }
 
  const telegramId = result.user.id?.toString()
  if (!telegramId) {
    return NextResponse.json({ error: 'Invalid user data' }, { status: 400 })
  }
 
  const user = await findOrCreateUser(telegramId)
  const res = NextResponse.json(user)
  res.cookies.set('session', await createSession(user.id), { httpOnly: true, secure: true, sameSite: 'none' })
  return res
}

이후 API는 두 방식 중 하나로 인증한다.

방식장점단점
요청마다 initData 검증서버 상태가 없다auth_date 만료 뒤에는 미니앱을 다시 열어야 한다
첫 검증 뒤 자체 세션·JWT 발급수명·무효화를 서버가 정한다세션 저장소나 토큰 관리가 필요하다

웹 버전 텔레그램에서는 미니앱이 iframe 안에서 열려 쿠키가 서드파티로 취급된다. 그래서 SameSite=None; Secure를 주고, 브라우저가 서드파티 쿠키를 막으면 Authorization 헤더 토큰으로 바꾼다. 두 방식의 비교는 세션 기반 인증과 토큰 기반 인증에 있다.

개발 환경 인증 우회 주의

텔레그램 밖(일반 브라우저)에서는 initData가 없어서, 개발 중에는 환경 변수(BYPASS_TELEGRAM_AUTH=true 등)로 검증을 건너뛰게 만들곤 한다.

  • 우회 플래그는 서버 쪽 변수로만 판단한다. NEXT_PUBLIC_ 접두사가 붙은 변수는 브라우저 번들에 그대로 노출된다.
  • 운영 환경에서는 플래그가 켜져 있어도 무시하도록 NODE_ENV === 'production'일 때 우회를 막아 둔다. 우회가 운영에 새면 누구나 아무 사용자로 로그인할 수 있다.

핵심 정리

  • 클라이언트는 서명된 initData 원본을 서버로 보내고, initDataUnsafe는 표시용으로만 쓴다.
  • 서버는 hash를 뺀 정렬된 data-check-string을 봇 토큰 기반 HMAC-SHA256으로 검증하고, auth_date로 만료를 확인한다.
  • 실패하면 403. 성공하면 자체 세션·JWT를 발급해 이후 요청을 인증한다.
  • 개발용 우회 플래그는 서버 전용·운영 차단.

관련 노트: 세션 기반 인증과 토큰 기반 인증, NestJS JWT 인증과 Passport, 쿠키와 세션, Next.js를 쓰는 이유, 정보 시스템 보안(해시·암호 알고리즘)

댓글

GitHub 계정으로 댓글을 남길 수 있어요.