개발

JWT 직접 만들어 써보기

node-forge를 이용해 JWT를 직접 만들어봤습니다.

2025.06.04

우리 사이트의 입장권을 만들어 볼까요?

NOTE

  1. 서버에서 인증 정보를 관리하기 위해 주로 세션과 JWT를 많이 사용합니다.
  2. 라이브러리를 사용하려다가 '이 정도는 직접 만들어볼 수 있지 않을까?' 하는 생각에 직접 구현해본 경험을 공유드리고자 합니다.
  3. "이렇게 하는구나" 만 보고 실제 구현은 검증된 라이브러리를 사용하는 것을 추천드립니다.

1. JWT란?

  • RFC 문서: https://datatracker.ietf.org/doc/html/rfc7519
  • JWT(JSON Web Token)의 사전적 정의와 개요 등은 위의 RFC 문서에서 확인하실 수 있습니다.
  • 저는 암호화 규격, 암호화 대상 정보를 JSON으로 표현하고, 암호화하여 사용하는 토큰이라고 이해하고 있습니다. (정확한 정의는 아니지만, 외우기 쉽습니다.)
  • 이렇게 만들어진 토큰을 클라이언트와 서버가 교환하면서 인증 정보나 다양한 정보를 안전하게 주고받을 수 있습니다.
  • 즉, 우리 업장의 입장권이라고 생각하시면 더 이해하기 쉬울 것입니다.

2. 왜 세션 방식 대신 토큰을 사용할까요? (장점)

  • 제가 JWT를 사용하는 가장 큰 이유는 서버에서 인증 정보를 보관하지 않아도 되기 때문입니다.
  • 아래는 챗GPT가 정리해준 JWT의 장점입니다.
  1. 무상태성(Stateless)
    • 확장성: JWT는 서버에서 상태를 저장할 필요가 없습니다. 즉, 서버 간 로드 밸런싱이나 마이크로서비스 아키텍처에서 매우 유리합니다. 서버 간 세션 공유, 동기화 작업이 필요 없어 확장성이 뛰어납니다.
  2. 클라이언트 측 상태 관리
    • JWT는 클라이언트에서 상태를 관리하기 때문에 서버의 부담이 줄어듭니다. 이 점이 상당히 큽니다.
  3. 토큰 자체에 정보 포함(Self-contained)
    • 토큰 안에 필요한 정보가 들어있어 데이터베이스 의존성이 줄어듭니다. 이는 성능 측면에서 유리합니다.
  4. 보안성
    • 서명 및 무결성 보장: JWT는 비밀키로 서명되어 변조를 방지합니다. 공격자가 토큰을 변조할 수 없고, 클라이언트와 서버 간 데이터 무결성도 보장됩니다. (단, 이 부분은 신경 써서 구현해야 합니다.)
  5. Cross-Domain 및 모바일 지원
    • JWT는 도메인 간 요청에서 쿠키에 의존하지 않으므로, API 서버와 클라이언트의 도메인이 다를 때 매우 유리합니다.
  6. 편리한 표준 및 호환성
    • JWT는 표준화된 JSON 포맷을 사용하여 다양한 언어, 플랫폼에서 사용할 수 있습니다. 관련 라이브러리도 많아 개발자들이 쉽게 구현할 수 있습니다.

3. 단점은 무엇일까요?

  • JWT를 도입하면서 가장 걱정했던 부분은 토큰의 탈취 위험이었습니다.
  • 클라이언트에서 토큰이 탈취될 경우, 공격자가 토큰 소유자인 척 행동할 수 있기 때문에 많은 고민이 필요했습니다.
  • 아래는 챗GPT가 정리해준 JWT의 단점입니다.
  1. 토큰 크기
    • 쿠키 및 헤더 크기 제한: 웹 브라우저는 HTTP 헤더나 쿠키의 크기에 제한이 있습니다. 토큰 크기가 커지면 이 제한에 도달할 수 있으며, 이는 클라이언트와 서버 간 통신에 문제를 일으킬 수 있습니다.
  2. 보안 문제
    • 탈취 및 악용 위험: JWT는 클라이언트 측에 저장되므로, 탈취될 경우 공격자가 해당 토큰을 사용해 권한이 부여된 리소스에 접근할 수 있습니다. 특히 만료 시간이 길거나, 토큰 재발급이 어려운 구조에서는 위험이 커집니다.
    • 토큰 재사용 공격: JWT는 기본적으로 무상태이기 때문에, 토큰을 즉시 취소하는 방법이 없습니다. 따라서 토큰이 유효한 기간 동안 탈취되거나 오용될 수 있습니다.
  3. 토큰 무효화의 어려움
    • 토큰의 즉각적인 무효화가 어렵습니다. JWT는 서버가 상태를 저장하지 않기 때문에, 사용자가 로그아웃하거나 비밀번호를 변경해도 토큰이 만료될 때까지 유효할 수 있습니다. 이를 해결하려면 블랙리스트나 짧은 토큰 유효 기간을 설정해야 하며, 이로 인해 관리가 복잡해질 수 있습니다.
  4. 유효 기간 관리
    • 보안을 위해 짧은 유효 기간을 설정하면 사용자는 자주 다시 로그인해야 하는 불편함이 있습니다. 반면, 긴 유효 기간을 설정하면 탈취된 토큰이 장기간 사용될 위험이 있습니다.
  5. 정보 노출 위험
    • 민감한 정보 포함 위험: JWT는 클라이언트 측에 저장되며, Base64 URL 인코딩만 되어 있어 평문이 쉽게 노출될 수 있습니다. 따라서 페이로드에 민감한 정보를 포함하지 않아야 합니다.
    • 토큰의 공개 가능성: URL에 토큰을 포함하여 전달할 경우, 로그나 브라우저 기록 등에 토큰이 남아 보안 취약점이 생길 수 있습니다.

4. 장점과 단점을 정리해보겠습니다.

  • 세션 방식 역시 소규모 웹 어플리케이션에서는 충분히 현역이므로, 어떤 방식이 더 좋다 나쁘다 단정할 수는 없습니다.
  • 장단점을 이해하고, 상황에 맞게 단점을 보완하면서 적용하는 것이 가장 중요합니다.
  • 아래는 JWT와 세션을 비교한 표입니다.

표는 PC에서 확인할 수 있습니다!


5. JWT의 구조

  • jwt.io: https://jwt.io/ (JWT를 간단하게 생성, 디코딩, 검증, 개념을 제공하는 매우 유용한 사이트입니다.)
  • 위 사이트에서 제공하는 예제를 바탕으로 설명드리겠습니다.

각 색상의 JSON을 각각 base64로 인코딩하고, "."으로 연결하면 JWT가 완성됩니다.

IMPORTANT

  • - 빨간색: 헤더
  • - 보라색: 페이로드
  • - 하늘색:서명
  • 서명 = 암호화(헤더.페이로드.시크릿키)
  • => [헤더.페이로드.서명] (.으로 연결하는게 포인트입니다.)
  • 간단하게 코드로 표현하면 다음과 같습니다.
// 이 코드는 구조를 설명하는 예시일 뿐, 실제 구현 코드는 아래에 있습니다. /** * base64 문자열을 JWT에 사용할 수 있는 형태로 변환 * @param base64str * @returns */ function _formatSafeBase64(base64str: string): string { return base64str.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); } // 암호화 함수 function encrypt(서명대상, 시크릿키) { // 서명 } // 헤더 Json 형태 const header = { alg: "HS256", typ: "JWT" } // 페이로드 Json 형태 const payload = { sub: "123456789", name: "seokho", iat: 1516239022 } // 헤더 const Base64Header = _formatSafeBase64(Buffer.from(JSON.stringify(header)).toString('base64')); // 페이로드 const Base64Payload = _formatSafeBase64(Buffer.from(JSON.stringify(payload)).toString('base64')); // 헤더 + . + 페이로드 const plain = Base64Header + '.' + Base64Payload; // 서명 const signature = encrypt(plain, 'secretKey') // 완성 const JWT = plain + '.' + signature

6. JWT의 세 가지 항목을 자세히 살펴보겠습니다.

  • 위에서 조합해야 하는 값은 총 3가지입니다.
  • 아래 항목들을 하나씩 살펴보겠습니다.
  • 외우실 필요는 없습니다. 이런 항목들이 있다는 정도로만 이해하셔도 충분합니다.
    • 헤더
    • 페이로드
    • 서명

6-1 Header(헤더)

헤더는 두 가지 키로 구성됩니다.

WARNING

주의: alg 항목에 none 값은 절대 사용하지 마십시오.

1. typ: 토큰의 타입

표는 PC에서 확인할 수 있습니다!


2. alg

  • JWT 해싱 알고리즘 (여기서는 JWE는 다루지 않으므로 JWT에서 사용하는 서명 알고리즘만 작성하였습니다.)
  • 잘 모르셔도 걱정하지 마십시오. 사용하는 방법을 익히고 나면 다양한 알고리즘을 시도해보실 수 있습니다.

표는 PC에서 확인할 수 있습니다!


6-2 Payload(페이로드)


페이로드에는 토큰에 담을 정보를 입력합니다. 키의 개수는 정해져 있지 않으며, 크게 세 가지로 구분할 수 있습니다.

  • 여기서 자주 등장하는 클레임(Claims)은 페이로드에서 사용하는 {key: value} 쌍을 의미합니다.
  • 모든 클레임의 이름은 자유롭게 정할 수 있습니다.

1. 등록된 클레임: JWT 표준에 정의된 클레임으로, 주로 메타데이터 정의에 사용됩니다.

표는 PC에서 확인할 수 있습니다!


2. 공개 클레임: 어플리케이션 간 사전에 정의된 클레임으로, 주로 시스템 정보 정의에 사용됩니다.

표는 PC에서 확인할 수 있습니다!


3. 비공개 클레임: 등록되지 않은 클레임으로, 특정 어플리케이션의 고유 요구사항을 정의합니다.

표는 PC에서 확인할 수 있습니다!


6-3 Signature(서명)

서명 부분에서는 위에서 설명한 encrypt 함수를 구현한다고 보시면 됩니다.

// 헤더 + . + 페이로드 const plain = Base64Header + '.' + Base64Payload; // 서명 (이 부분) const signature = encrypt(plain, 'secretKey')
  • 여기서 구현하는 encrypt 함수는 6-1 Header(헤더) 부분의 alg 값에 맞게 구현하셔야 합니다.
  • 가장 기본이 되는 HS256을 예시로 구현해보겠습니다.

표는 PC에서 확인할 수 있습니다!

  • HMAC using SHA-256 => HMAC + SHA-256, 즉 라이브러리로 HMAC, SHA-256을 생성할 수 있으면 쉽게 구현할 수 있습니다.
  • 바로 코드로 살펴보겠습니다.
import * as forge from 'node-forge'; /** * base64 문자열을 JWT에 사용할 수 있는 형태로 변환 * @param base64str * @returns */ function _formatSafeBase64(base64str: string): string { return base64str.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); } // 헤더 + '.' + 페이로드 const plain = Base64Header + '.' + Base64Payload; // 시크릿키 const secretKey = '설정하신 secretKey'; // HMAC 객체 생성 및 SHA-256 해시 알고리즘 설정 const hmac = forge.hmac.create(); hmac.start('sha256', secretKey); // 서명할 데이터 업데이트 hmac.update(data); // HMAC-SHA256 서명 생성 (HS256 서명) const signature = _formatSafeBase64(forge.util.encode64(hmac.digest().getBytes())); // 완성! const JWT = plain + '.' + signature;

7. 여기까지 이론을 이해하셨다면, 전체 코드를 공개합니다.

NOTE

  • HS256 외 다른 알고리즘 사용 부분은 삭제하고 올린 코드라 어색할 수 있습니다.
  • 코드에는 반영되어 있지 않지만, forge와 같이 암복호화하는 부분에는 try-catch로 에러 핸들링을 추가하시면 더 안전합니다.
  • HS256 과정을 이해하셨다면, HS 시리즈는 모두 구현 가능하며, RSA 등도 sign, verify 부분에 if문만 추가하여 지원할 수 있습니다.
  1. JWT 서명 생성
  2. JWT 서명 검증
import * as forge from 'node-forge'; /** * 참고 * https://jwt.io/ * https://datatracker.ietf.org/doc/html/rfc7519 */ /** * JWT Manager * default alg = HS256 */ export class JwtManager { private secret: string; constructor() { this.secret = "imsiSecret"; // 코드 공개용 임시 시크릿키 } /** * JWT 서명 생성 * @param alg - 알고리즘(HS256 지원) * @param payload - 본문 * @param min - 토큰 유효 시간(분 단위) * @returns */ sign(alg: string, payload: object, min: number): string | null { alg = alg.toUpperCase(); const header = { 'alg': '', 'typ': 'JWT', }; if (alg === 'HS256') { header.alg = alg; const nowSecond = Math.floor(Date.now() / 1000); payload = { ...payload, exp: nowSecond + (min * 60), }; const base64Header = this._formatSafeBase64(Buffer.from(JSON.stringify(header)).toString('base64')); const base64Payload = this._formatSafeBase64(Buffer.from(JSON.stringify(payload)).toString('base64')); const plain = `${base64Header}.${base64Payload}`; const hmac = forge.hmac.create(); hmac.start('sha256', plain); hmac.update(this.secret); const signature = forge.util.encode64(hmac.digest().getBytes()); return `${plain}.${signature}`; } return null; } /** * JWT 서명 검증 * @param token * @returns */ verify(alg: string, token: string): boolean { alg = alg.toUpperCase(); if (!token) { return false; } if (!this._isValid(token)) { return false; } const [header, payload, signature] = token.split('.'); if (!header && !payload && !signature) { return false; } const plain = `${header}.${payload}`; if (alg === 'HS256') { const hmac = forge.hmac.create(); hmac.start('sha256', plain); hmac.update(this.secret); const expectedSignature = this._formatSafeBase64(forge.util.encode64(hmac.digest().getBytes())); return signature === expectedSignature; } return false; } /** * JWT 만료 시간 체크 후 유효성 반환 * @param token * @returns */ private _isValid(token: string): boolean { const payload = this.getPayload(token); if (!payload) { return false; } return Date.now() < payload.exp * 1000; } /** * JWT 페이로드 추출 * @param token * @returns */ getPayload(accessToken: string) { const [header, payload, signature] = accessToken.split('.'); if (!payload) { return null; } return JSON.parse(Buffer.from(payload, 'base64').toString()); } /** * base64 문자열을 JWT에 사용할 수 있는 형태로 변환 * @param base64str * @returns */ private _formatSafeBase64(base64str: string): string { return base64str.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); } }

8. 테스트 및 검증

// jwt.spec.ts describe('위에서 만든 JWT 생성, 유효성 검증', () => { const jwtManager = new JwtManager(); const accessPayload = { 'sub': '123456789', 'name': 'seokho-example', 'test': 'test-jwt', }; it('발급 및 유효성 검증', () => { const accessToken = jwtManager.sign('HS256', accessPayload, 1); if (accessToken !== null) { const [header, payload, sign] = accessToken.split('.'); console.log('header = ' + header); console.log('payload = ' + payload); console.log('sign = ' + sign); expect(jwtManager.verify('HS256', accessToken)).toBe(true); } }); });

  • 정상적으로 발급 및 검증이 완료되었습니다!
  • header = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • payload = eyJzdWIiOiIxMjM0NTY3ODkiLCJuYW1lIjoic2Vva2hvLWV4YW1wbGUiLCJ0ZXN0IjoidGVzdC1qd3QiLCJleHAiOjE3MjQ2NjI2NTR9
  • sign = F7JsDKcIOjyWwqan89QSPbgxkuUCy7BGFEk0ep8g688
  • JWT = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkiLCJuYW1lIjoic2Vva2hvLWV4YW1wbGUiLCJ0ZXN0IjoidGVzdC1qd3QiLCJleHAiOjE3MjQ2NjI2NTR9.F7JsDKcIOjyWwqan89QSPbgxkuUCy7BGFEk0ep8g688

  • 이제 jwt.io 사이트에서 완성된 JWT를 붙여넣어 보시면, 직접 만든 헤더와 페이로드가 그대로 노출되는 것을 확인하실 수 있습니다. 즉, 잘 만들어졌고 이제 잘 활용하시면 됩니다!
  • 토큰 전략에 대한 포스팅도 추후에 올릴 예정입니다.

9. 완벽한 벤치마크는 아니지만 성능 비교도 해보았습니다.

IMPORTANT

테스트 환경

  • 직접 구현한 JWT가 라이브러리보다 빠를지 궁금하여 성능을 비교해보았습니다.
  • 직접 만든 node-forge 기반 JwtManager vs 라이브러리 jsonwebtoken, jose
  • 하드웨어: M2 맥미니 (RAM 24GB, SSD 500GB)
  • Node 버전 및 프레임워크: Node v20.12.0 (nestjs + jest)

1번 node-forge 기반 JwtManager

describe('JwtManager', () => { const jwtManager = new JwtManager(); const accessPayload = { 'sub': '123456789', 'name': 'seokho-example', 'test': 'test-jwt', }; it('직접 만든 jwt sign', () => { const start = performance.now(); const accessToken = jwtManager.sign('HS256', accessPayload, 1); const end = performance.now(); console.log(`직접 만든 JWT 생성 시간: ${(end - start).toFixed(6)} ms`); }); });

2번 npm i jsonwebtoken

import * as jwt from 'jsonwebtoken'; describe('jsonwebtoken', () => { const secretKey = 'secret'; const accessPayload = { sub: '123456789', name: 'seokho-example', test: 'test-jwt', }; it('jsonwebtoken sign', () => { const start = performance.now(); const accessToken = jwt.sign(accessPayload, secretKey); const end = performance.now(); console.log(`jsonwebtoken JWT 생성 시간: ${(end - start).toFixed(6)} ms`); }); });

3번 npm i jose

import { SignJWT } from 'jose'; describe('jose', () => { const secretKey = new TextEncoder().encode('secret'); const accessPayload = { sub: '123456789', name: 'seokho-example', test: 'test-jwt', }; it('jose sign', async () => { const start = performance.now(); const accessToken = await new SignJWT(accessPayload) .setProtectedHeader({ alg: 'HS256' }) .sign(secretKey); const end = performance.now(); console.log(`jose JWT 생성 시간: ${(end - start).toFixed(6)} ms`); }); });

성능 측정 결과

표는 PC에서 확인할 수 있습니다!


마무리

  • 직접 만든 JwtManager가 가장 빠르게 서명을 완료했습니다!
  • 하지만 라이브러리들은 다양한 암호화, 검증 기능을 제공하므로 단순 속도만으로 비교하기는 어렵습니다.
  • 결론적으로 JWT의 개념만 이해하고, 실제 구현은 검증된 라이브러리를 사용하는 것을 추천드립니다.
  • 그럼에도 불구하고 직접 만들어보는 과정은 매우 유익하다고 생각합니다.
  • 긴 글 읽어주셔서 감사합니다. 좋은 하루 보내세요!

댓글

0