세션과 토큰은 로그인 정보를 저장하는 방법이 아니다

vx_developer·2일 전

개발하다가

목록 보기
39/41
post-thumbnail

프로그래밍을 처음 배울 때 세션과 토큰은 로그인한 사용자 정보를 저장하는 두 가지 방법이라고 배운다.

const token = createToken({
  userId: user.id,
});

return token;

클라이언트가 이후 요청에 토큰을 보내면 서버가 로그인한 사용자를 알아볼 수 있다고 설명한다.

Authorization: Bearer eyJ...

기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 사용자 ID를 어디에 저장할지보다 더 많은 판단이 필요하다.

  • 인증 상태는 언제 만료되어야 하는가?
  • 사용자가 로그아웃하면 이미 발급된 상태를 어떻게 무효화하는가?
  • 비밀번호나 권한이 변경되면 기존 인증 상태에 언제 반영되는가?
  • 인증 정보가 탈취되면 피해 범위를 어떻게 제한하는가?
  • 여러 기기에서 로그인한 상태를 각각 관리할 수 있는가?
  • 브라우저는 인증 정보를 어디에 보관하고 어떻게 전송해야 하는가?
  • 짧게 만료되는 인증 상태를 어떻게 안전하게 갱신하는가?

세션과 토큰은 단순히 로그인 정보를 저장하는 방법이 아니다.

세션과 토큰은 한 번 확인한 사용자 신원을 여러 요청에서 다시 증명할 수 있도록 전달하면서, 그 신뢰를 만료·갱신·철회하는 인증 상태의 생명주기를 설계하는 방법이다.


로그인 요청이 끝나면 서버는 다음 요청을 같은 사용자라고 알 수 없다

크리스가 공연 티켓 예매 서비스를 개발한다고 생각해 보자.

사용자가 로그인한 뒤 공연 목록을 조회하고 좌석을 선택해 결제한다.

POST /login
GET /events
GET /events/123/seats
POST /reservations
POST /payments

HTTP 요청은 각각 독립적으로 도착한다. 로그인 요청에서 비밀번호를 확인했더라도 다음 요청이 자동으로 같은 사용자와 연결되지는 않는다.

async function createReservation(request: Request) {
  const userId = request.body.userId;

  return reservationService.create({
    userId,
    seatId: request.body.seatId,
  });
}

클라이언트가 본문에 보낸 userId를 그대로 믿으면 다른 사용자의 ID를 입력해 대신 예약할 수 있다.

외부 입력은 검증 전까지 신뢰할 수 없다. 특히 사용자가 직접 전달한 사용자 ID는 본인의 신원을 증명하지 않는다.

서버가 로그인 과정에서 확인한 신원을 이후 요청과 연결해야 한다.

async function createReservation(request: Request) {
  const currentUser =
    await authenticationService.authenticate(request);

  return reservationService.create({
    userId: currentUser.id,
    seatId: request.body.seatId,
  });
}

이제 사용자 ID는 요청 본문이 아니라 검증된 인증 상태에서 가져온다.

세션과 토큰이 해결하는 첫 번째 문제는 사용자 정보를 저장하는 일이 아니다. 서로 독립적인 요청들이 동일한 인증된 사용자에게서 왔는지 확인하는 일이다.


세션은 서버에 저장된 인증 상태를 가리키는 식별자다

서버 세션 방식을 사용하면 로그인 성공 후 서버가 세션 레코드를 만든다.

const session = await sessionRepository.create({
  userId: user.id,
  createdAt: new Date(),
  expiresAt: addDays(new Date(), 7),
  revokedAt: null,
});

클라이언트에는 사용자 전체 정보가 아니라 예측하기 어려운 세션 ID를 전달한다.

Set-Cookie: session_id=random-opaque-value; HttpOnly; Secure; SameSite=Lax

다음 요청에서 브라우저가 쿠키를 전송하면 서버는 세션 ID로 인증 상태를 조회한다.

const sessionId =
  request.cookies.get("session_id");

const session =
  await sessionRepository.findById(sessionId);

세션 레코드는 다음과 같은 정보를 가질 수 있다.

type Session = {
  id: string;
  userId: string;
  createdAt: Date;
  expiresAt: Date;
  lastSeenAt: Date;
  revokedAt: Date | null;
};

세션 ID 자체에는 사용자 이름이나 권한을 담을 필요가 없다. 서버의 세션 데이터를 찾기 위한 임의의 식별자면 충분하다.

브라우저
session_id = abc123
        ↓
서버 세션 저장소
abc123 → userId, expiresAt, revokedAt
        ↓
사용자 데이터 조회

세션 방식에서는 서버 저장소가 현재 인증 상태의 Source of Truth가 된다.

  • 세션이 존재하는가?
  • 만료되지 않았는가?
  • 철회되지 않았는가?
  • 연결된 계정이 아직 활성 상태인가?

서버가 요청마다 이 상태를 확인하면 특정 세션을 즉시 종료하기 쉽다. 대신 많은 사용자의 세션을 저장하고 빠르게 조회할 수 있는 저장소가 필요하다.


토큰은 서버가 검증할 수 있는 인증 주장을 전달한다

토큰 방식에서는 서버가 인증 결과를 검증 가능한 형태로 발급할 수 있다.

const accessToken = await tokenService.sign({
  subject: user.id,
  expiresAt: addMinutes(new Date(), 15),
});

클라이언트는 이후 요청에 토큰을 함께 보낸다.

Authorization: Bearer <access-token>

서버는 서명과 만료 시간을 검증해 토큰이 변조되지 않았는지 확인한다.

const claims =
  await tokenService.verify(accessToken);

const currentUserId = claims.subject;

토큰에는 일반적으로 다음과 같은 주장이 포함될 수 있다.

type AccessTokenClaims = {
  subject: string;
  issuedAt: number;
  expiresAt: number;
  issuer: string;
  audience: string;
  tokenId: string;
};

서명된 토큰은 내용을 숨기는 구조와 같지 않다. 형식에 따라 토큰 본문은 클라이언트나 제삼자가 읽을 수 있다.

따라서 토큰에 다음 정보를 넣어서는 안 된다.

const unsafeClaims = {
  userId: user.id,
  passwordHash: user.passwordHash,
  cardNumber: user.cardNumber,
  privateNotes: user.privateNotes,
};

서명은 내용이 변조되지 않았음을 검증할 수 있게 하지만 민감한 내용을 자동으로 비공개로 만들지는 않는다.

토큰은 사용자 정보를 담는 작은 데이터베이스가 아니다. 서버가 특정 시점에 확인한 신원과 제한된 인증 조건을 이후 요청에 전달하는 증명서에 가깝다.


세션과 토큰은 서로 반대되는 하나의 선택지가 아니다

세션은 서버 저장 방식이고 토큰은 클라이언트 전달 형식이라고 단순하게 구분하기 쉽다. 실제 서비스에서는 둘을 함께 사용할 수 있다.

예를 들어 브라우저는 세션 ID를 쿠키로 보낼 수 있다.

Cookie
  ↓
세션 ID
  ↓
서버 세션 저장소 조회

모바일 앱은 짧게 만료되는 액세스 토큰을 API에 보낼 수 있다.

Authorization Header
  ↓
Access Token
  ↓
서명과 만료 검증

토큰을 사용하더라도 갱신 토큰, 기기 정보, 철회 상태를 서버에 저장할 수 있다.

type RefreshSession = {
  id: string;
  userId: string;
  tokenHash: string;
  deviceId: string;
  expiresAt: Date;
  revokedAt: Date | null;
};

반대로 세션 ID도 클라이언트가 서버에 전달하는 일종의 토큰 역할을 한다.

중요한 질문은 “세션인가 JWT인가” 하나가 아니다.

  • 서버가 요청마다 어떤 상태를 조회하는가?
  • 클라이언트가 어떤 인증 정보를 전달하는가?
  • 인증 상태를 즉시 철회할 수 있는가?
  • 여러 서버가 같은 인증 상태를 확인할 수 있는가?
  • 만료와 갱신은 어떻게 처리하는가?
  • 탈취된 인증 정보의 피해 범위는 어느 정도인가?

세션과 토큰은 제품 이름처럼 하나를 고르는 문제가 아니다. 인증 상태를 어디에서 관리하고 어떤 증거를 요청 사이에 전달할지 조합하는 설계 문제다.


인증 상태에는 절대 만료와 비활성 만료가 필요할 수 있다

사용자가 한 번 로그인한 뒤 영구적으로 인증 상태가 유지되면 탈취된 정보도 장기간 사용할 수 있다.

type Session = {
  createdAt: Date;
  expiresAt: Date;
  lastSeenAt: Date;
};

세션에는 서로 다른 만료 기준을 둘 수 있다.

절대 만료
생성 후 7일이 지나면 사용 여부와 관계없이 종료

비활성 만료
마지막 사용 후 30분 동안 요청이 없으면 종료

검증 코드는 두 조건을 확인할 수 있다.

const now = new Date();

const isExpired =
  session.expiresAt <= now ||
  addMinutes(session.lastSeenAt, 30) <= now;

if (isExpired) {
  throw new AuthenticationExpiredError();
}

모든 기능에 같은 신뢰 시간이 적합한 것은 아니다.

상황고려할 수 있는 인증 요구
공연 목록 조회일반 인증 상태
저장된 결제수단 확인더 짧은 신뢰 시간
결제수단 변경최근 로그인 또는 추가 인증
계정 이메일 변경비밀번호 재확인·추가 인증
모든 기기 로그아웃강한 재인증

사용자가 로그인 상태라는 사실과 민감한 작업을 수행할 만큼 최근에 신원을 증명했다는 사실은 다르다.

const recentlyAuthenticated =
  session.authenticatedAt >
  subtractMinutes(new Date(), 10);

if (!recentlyAuthenticated) {
  throw new ReauthenticationRequiredError();
}

인증 상태의 유효 기간은 편의성만으로 결정하지 않는다. 정보 탈취 시의 피해와 사용자가 다시 로그인해야 하는 비용 사이에서 정한다.


짧은 액세스 토큰과 긴 갱신 상태는 책임이 다르다

액세스 토큰을 매우 오래 유효하게 만들면 구현은 단순해 보인다.

const accessToken = await tokenService.sign({
  subject: user.id,
  expiresAt: addYears(new Date(), 1),
});

그러나 토큰이 탈취되면 최대 1년 동안 사용될 수 있다. 서버가 토큰 상태를 별도로 확인하지 않는다면 즉시 종료하기도 어렵다.

액세스 토큰은 짧게 유지하고 별도의 갱신 상태를 사용할 수 있다.

const accessToken = await tokenService.sign({
  subject: user.id,
  expiresAt: addMinutes(new Date(), 15),
});

const refreshToken =
  await refreshTokenService.issue({
    userId: user.id,
    expiresAt: addDays(new Date(), 30),
  });

각 인증 정보의 책임은 다르다.

인증 정보목적일반적인 특성
액세스 토큰API 요청 인증짧은 만료, 자주 사용
갱신 토큰새 액세스 토큰 발급긴 만료, 제한된 경로에서만 사용
세션 레코드기기별 인증 상태 관리서버에서 철회·추적 가능

갱신 토큰은 일반 API 호출에 사용하지 않아야 한다.

POST /auth/refresh
Cookie: refresh_token=<opaque-secret>

서버는 갱신 상태를 검증한 뒤 새로운 액세스 토큰을 발급한다.

const refreshSession =
  await refreshSessionRepository.findValid(
    presentedRefreshToken
  );

if (!refreshSession) {
  throw new InvalidRefreshTokenError();
}

return tokenService.sign({
  subject: refreshSession.userId,
  expiresAt: addMinutes(new Date(), 15),
});

액세스 토큰과 갱신 토큰을 나누는 이유는 단순히 만료 시간을 다르게 저장하기 위해서가 아니다. 자주 노출되는 인증 정보의 수명을 줄이고, 장기 인증 상태는 더 제한된 경로에서 관리하기 위해서다.


갱신 토큰은 원문보다 검증 가능한 형태로 저장하는 편이 안전하다

서버가 갱신 토큰 원문을 데이터베이스에 저장한다고 생각해 보자.

await refreshSessionRepository.create({
  userId: user.id,
  refreshToken,
});

데이터베이스가 노출되면 공격자가 저장된 토큰을 그대로 사용할 수 있다.

서버가 다시 원문을 보여줄 필요가 없다면 안전한 비교 형태로 저장할 수 있다.

const refreshToken =
  crypto.randomUUID() + crypto.randomUUID();

const refreshTokenHash =
  await tokenHasher.hash(refreshToken);

await refreshSessionRepository.create({
  userId: user.id,
  tokenHash: refreshTokenHash,
  expiresAt,
});

클라이언트가 갱신 토큰을 제출하면 저장된 값과 검증한다.

const matches =
  await tokenHasher.verify(
    presentedRefreshToken,
    refreshSession.tokenHash
  );

갱신 성공 시 기존 토큰을 폐기하고 새 토큰을 발급하는 회전 정책도 사용할 수 있다.

await database.transaction(async (tx) => {
  await tx.refreshSessions.revoke(
    refreshSession.id
  );

  await tx.refreshSessions.create({
    userId: refreshSession.userId,
    tokenHash: newRefreshTokenHash,
    familyId: refreshSession.familyId,
    expiresAt: newExpiresAt,
  });
});

이미 사용된 갱신 토큰이 다시 제출되면 탈취 또는 중복 사용 가능성을 의심할 수 있다.

if (refreshSession.revokedAt) {
  await refreshSessionRepository.revokeFamily(
    refreshSession.familyId
  );

  throw new RefreshTokenReuseDetectedError();
}

토큰 회전은 요청마다 문자열을 바꾸는 기능이 아니다. 장기 인증 정보가 복제되었는지 발견하고 관련 인증 상태를 폐기할 수 있게 하는 생명주기 정책이다.


브라우저 저장 위치는 XSS와 CSRF 위험을 함께 고려해야 한다

브라우저 애플리케이션에서 인증 토큰을 localStorage에 저장하는 예제를 자주 볼 수 있다.

localStorage.setItem(
  "accessToken",
  accessToken
);

자바스크립트가 읽을 수 있으므로 요청 헤더에 붙이기는 쉽다. 그러나 페이지에서 악성 스크립트가 실행되는 XSS 문제가 발생하면 토큰도 읽혀 외부로 전송될 수 있다.

브라우저가 서버 인증 정보를 쿠키로 관리하게 할 수 있다.

Set-Cookie: session_id=<opaque-value>; HttpOnly; Secure; SameSite=Lax; Path=/

각 속성은 서로 다른 책임을 가진다.

  • HttpOnly는 브라우저 자바스크립트가 쿠키를 직접 읽지 못하게 한다.
  • Secure는 HTTPS 연결에서만 쿠키를 전송하게 한다.
  • SameSite는 사이트 간 요청에 쿠키가 전송되는 범위를 제한한다.
  • Path는 쿠키가 전송될 경로 범위를 정한다.
  • 만료 설정은 브라우저에 남아 있는 시간을 제한한다.

쿠키는 브라우저가 자동으로 전송하므로 CSRF 위험도 고려해야 한다. SameSite 설정, CSRF 토큰, Origin 검사처럼 요청이 의도한 사이트에서 시작되었는지 확인하는 방법이 필요할 수 있다.

const csrfToken =
  request.headers.get("X-CSRF-Token");

await csrfProtection.verify({
  csrfToken,
  sessionId,
});

인증 정보를 어느 저장소에 넣을지는 편리함만으로 결정할 수 없다.

방식주요 장점주요 주의점
HttpOnly 쿠키자바스크립트 직접 접근 제한CSRF 방어 필요
메모리 저장페이지 종료 시 사라짐새로고침과 탭 간 유지 설계 필요
Web Storage구현이 단순함XSS 발생 시 탈취 가능
모바일 보안 저장소OS 수준 보호 활용 가능기기·플랫폼별 구현 필요

어떤 방식을 사용하더라도 XSS, CSRF, HTTPS, 만료, 철회 정책을 함께 설계해야 한다.


토큰 검증은 서명 확인 하나로 끝나지 않는다

서명만 유효하면 모든 토큰을 받아들이는 코드는 충분하지 않다.

const claims =
  await tokenService.verifySignature(token);

서버는 토큰이 현재 요청에서 사용될 수 있는지도 확인해야 한다.

const claims = await tokenService.verify(token, {
  issuer: "ticket-service",
  audience: "ticket-api",
  currentTime: new Date(),
});

검증할 수 있는 항목에는 다음이 포함된다.

  • 서명이 신뢰할 수 있는 키로 생성되었는가?
  • 토큰이 만료되지 않았는가?
  • 아직 사용하면 안 되는 토큰은 아닌가?
  • 예상한 발급자인가?
  • 현재 API를 대상으로 발급된 토큰인가?
  • 허용한 알고리즘으로 서명되었는가?
  • 필요한 경우 철회된 토큰이나 세션이 아닌가?

요청 헤더도 외부 입력이다. 외부 입력은 검증 전까지 신뢰할 수 없다.

const authorization =
  request.headers.get("Authorization");

if (!authorization?.startsWith("Bearer ")) {
  throw new UnauthenticatedError();
}

const token = authorization.slice("Bearer ".length);

if (token.length > 8192) {
  throw new UnauthenticatedError();
}

형식 검증 후에 암호학적 검증과 인증 상태 검사를 수행한다.

토큰을 해석할 수 있다는 사실과 토큰을 신뢰할 수 있다는 사실은 다르다. 본문을 디코딩한 결과만 보고 사용자 ID를 사용해서는 안 된다.


토큰 속 권한은 발급 시점의 복사본일 수 있다

액세스 토큰에 역할을 넣을 수 있다.

const accessToken = await tokenService.sign({
  subject: user.id,
  role: "admin",
  expiresAt: addMinutes(new Date(), 15),
});

이후 크리스의 역할이 member로 변경되어도 이미 발급된 토큰에는 admin이 남아 있을 수 있다.

10:00 admin 토큰 발급
10:03 역할을 member로 변경
10:10 기존 토큰은 여전히 admin 주장 포함
10:15 토큰 만료

토큰 주장은 발급 당시 상태의 복사본일 수 있다. 현재 권한의 Source of Truth와 항상 같지는 않다.

민감한 작업에서는 현재 권한을 서버 데이터에서 다시 확인할 수 있다.

const membership =
  await membershipRepository.findCurrent({
    userId: claims.subject,
    organizationId,
  });

await authorizationService.assertCan({
  membership,
  action: "refund_ticket",
});

다른 방법으로는 짧은 액세스 토큰, 권한 버전, 중요 작업의 재검증을 사용할 수 있다.

if (
  claims.permissionVersion !==
  user.permissionVersion
) {
  throw new AuthenticationStaleError();
}

어떤 방식을 선택할지는 권한 변경이 반영되어야 하는 속도와 요청마다 조회하는 비용에 따라 달라진다.

토큰에 값이 있다고 해서 그 값이 현재 비즈니스 상태의 영구적인 Source of Truth가 되는 것은 아니다.


로그아웃은 클라이언트에서 토큰을 삭제하는 것으로 끝나지 않을 수 있다

클라이언트에서 액세스 토큰을 삭제하면 해당 브라우저는 더 이상 토큰을 보내지 않는다.

authStore.clear();

그러나 공격자가 이미 토큰을 복사했다면 토큰 만료 전까지 계속 사용할 수 있다.

서버 세션 방식에서는 현재 세션을 철회할 수 있다.

await sessionRepository.revoke(session.id);

갱신 토큰 방식에서는 장기 인증 상태를 철회한다.

await refreshSessionRepository.revoke(
  refreshSession.id
);

짧게 만료되는 액세스 토큰은 남은 시간 동안 유효할 수 있다. 즉시 차단이 필요한 서비스라면 액세스 토큰 ID나 세션 버전을 추가로 확인하는 정책을 사용할 수 있지만, 모든 요청에서 서버 상태를 조회하는 비용이 생긴다.

로그아웃 범위도 구분해야 한다.

type LogoutCommand = {
  userId: string;
  sessionId: string;
  scope:
    | "current_session"
    | "other_sessions"
    | "all_sessions";
};

모든 기기에서 로그아웃한다면 해당 사용자의 모든 갱신 세션을 철회하고 인증 버전을 변경할 수 있다.

await database.transaction(async (tx) => {
  await tx.refreshSessions.revokeAllForUser(
    userId
  );

  await tx.users.incrementAuthVersion(userId);
});

로그아웃은 화면에서 로그인 정보를 없애는 일이 아니다. 앞으로 해당 인증 상태를 어느 범위까지 신뢰하지 않을지 결정하는 철회 작업이다.


여러 기기의 로그인은 하나의 사용자 아래 여러 인증 상태를 만든다

크리스가 노트북과 휴대전화에서 동시에 로그인할 수 있다고 생각해 보자.

type DeviceSession = {
  id: string;
  userId: string;
  deviceName: string;
  createdAt: Date;
  lastSeenAt: Date;
  expiresAt: Date;
  revokedAt: Date | null;
};

사용자 한 명과 인증 상태 하나를 동일하게 취급하면 기기별 제어가 어렵다.

User Chris
 ├─ MacBook session
 ├─ iPhone session
 └─ Work computer session

각 세션은 독립적인 생명주기를 가진다.

  • 한 기기에서 로그아웃해도 다른 기기는 유지할 수 있다.
  • 분실한 휴대전화의 세션만 철회할 수 있다.
  • 의심스러운 기기를 사용자에게 보여줄 수 있다.
  • 각 기기의 마지막 사용 시각을 확인할 수 있다.
  • 새로운 기기 로그인에 보안 알림을 보낼 수 있다.

기기 이름이나 User-Agent는 외부에서 전달되므로 절대적인 보안 증거로 신뢰해서는 안 된다. 사용자에게 세션을 구분해 보여주는 참고 정보로 사용할 수 있다.

세션의 Source of Truth는 사용자 객체의 isLoggedIn 값 하나가 아니다. 현재 유효한 기기별 인증 상태들의 집합이다.


인증 상태 갱신은 동시에 여러 번 실행되어도 안전해야 한다

브라우저에서 여러 API 요청이 동시에 액세스 토큰 만료를 발견할 수 있다.

GET /events       → 401
GET /reservations → 401
GET /profile      → 401

세 요청이 각각 갱신을 실행하면 같은 갱신 토큰이 여러 번 사용될 수 있다.

const responses = await Promise.all([
  refreshAccessToken(),
  refreshAccessToken(),
  refreshAccessToken(),
]);

갱신 토큰 회전을 사용한다면 첫 번째 요청이 기존 토큰을 폐기한 뒤 나머지 요청이 재사용 탐지로 처리될 수 있다.

프론트엔드에서는 하나의 갱신 작업을 공유할 수 있다.

let refreshPromise: Promise<string> | null = null;

async function getFreshAccessToken() {
  if (!refreshPromise) {
    refreshPromise = refreshAccessToken()
      .finally(() => {
        refreshPromise = null;
      });
  }

  return refreshPromise;
}

서버에서도 갱신 상태의 버전, 고유성, 짧은 중복 허용 정책 등을 명확히 정해야 한다.

자동 갱신은 “401이면 한 번 더 요청한다”는 코드로 끝나지 않는다.

  • 어떤 오류에서 갱신을 시도하는가?
  • 한 요청을 몇 번까지 재시도하는가?
  • 갱신 자체가 실패하면 로그아웃하는가?
  • 여러 요청의 갱신을 어떻게 합치는가?
  • 회전된 토큰의 재사용을 어떻게 판단하는가?
  • 원래 요청을 안전하게 다시 실행할 수 있는가?

특히 결제나 좌석 예약처럼 재실행 시 중복 결과가 생길 수 있는 요청은 별도의 멱등성 설계가 필요하다.


인증 실패는 만료·철회·권한 부족을 구분해야 한다

인증과 관련된 실패를 모두 같은 오류로 처리하면 클라이언트가 올바른 행동을 선택하기 어렵다.

type AuthenticationFailure =
  | "credentials_missing"
  | "access_token_expired"
  | "access_token_invalid"
  | "session_revoked"
  | "refresh_token_expired"
  | "account_unavailable"
  | "forbidden";

각 실패는 서로 다른 대응을 요구한다.

실패의미일반적인 대응
인증 정보 없음로그인하지 않은 요청로그인 화면 안내
액세스 토큰 만료짧은 인증 상태 종료갱신 시도
유효하지 않은 토큰변조·형식 오류 가능인증 상태 제거
세션 철회서버가 신뢰를 취소함다시 로그인
갱신 상태 만료자동 갱신 불가능다시 로그인
계정 사용 불가계정 정책 변경인증 상태 종료
권한 없음신원은 확인됐지만 행동 불가기능 접근 거부

그러나 외부 응답에 보안상 불필요한 내부 이유까지 모두 공개할 필요는 없다.

클라이언트가 취해야 할 행동에 필요한 수준으로 오류를 제공하고, 자세한 원인은 내부 보안 로그에 남길 수 있다.

세션과 토큰의 실패 처리는 사용자를 무조건 로그아웃시키는 한 줄짜리 로직이 아니다. 상태를 복구할 수 있는지, 신뢰를 즉시 철회해야 하는지 판단하는 과정이다.


인증 흐름은 로그인 이후의 모든 요청을 연결한다

티켓 예매 서비스의 인증 흐름을 정리하면 다음과 같다.

flowchart LR
    A[로그인 성공] --> B[세션·갱신 상태 생성]
    B --> C[클라이언트에 제한된 인증 정보 전달]
    C --> D[API 요청에 인증 정보 포함]
    D --> E[형식·서명·만료 검증]
    E --> F[현재 사용자 식별]
    F --> G[자원별 권한 확인]
    G --> H[요청 처리]
    E --> I{만료되었는가?}
    I -->|예| J[갱신 상태 검증]
    J --> K[새 액세스 토큰 발급]
    K --> D
    B --> L[로그아웃·보안 변경]
    L --> M[인증 상태 철회]

각 단계는 서로 다른 책임을 가진다.

  • 로그인은 최초의 신원 증명을 수행한다.
  • 세션과 토큰은 인증 결과를 다음 요청으로 전달한다.
  • 검증은 전달된 인증 정보가 현재 사용할 수 있는지 확인한다.
  • 권한 검사는 확인된 사용자가 특정 행동을 할 수 있는지 판단한다.
  • 갱신은 장기 인증 상태를 근거로 짧은 인증 상태를 다시 만든다.
  • 로그아웃과 보안 변경은 기존 신뢰를 철회한다.

세션과 토큰을 설계한다는 것은 값을 하나 저장하고 읽는 일이 아니다. 신뢰가 만들어지고, 전달되고, 만료되고, 갱신되고, 철회되는 전체 흐름을 설계하는 일이다.


세션과 토큰을 설계하기 전에 물어봐야 할 질문

어떤 인증 상태를 전달하는가

  1. 로그인 후 클라이언트가 받는 값은 무엇인가?
  2. 그 값 자체에 사용자 정보가 있는가, 서버 상태를 가리키는가?
  3. 다음 요청에서 서버는 어떤 검증을 수행하는가?
  4. 사용자 ID를 요청 본문이 아닌 인증 결과에서 가져오는가?
  5. 인증 정보에 불필요한 개인정보가 포함되지 않는가?

Source of Truth는 어디에 있는가

  1. 서버 세션 저장소가 현재 인증 상태의 기준인가?
  2. 토큰의 주장은 발급 당시 상태의 복사본인가?
  3. 계정과 권한 변경을 어느 데이터에서 다시 확인하는가?
  4. 캐시된 사용자 정보가 현재 상태를 대신하고 있지 않은가?
  5. 세션 저장소가 사라지거나 사용할 수 없을 때 어떻게 동작하는가?

만료와 갱신을 어떻게 관리하는가

  1. 절대 만료와 비활성 만료가 필요한가?
  2. 액세스 토큰의 수명은 탈취 위험에 비해 적절한가?
  3. 갱신 상태는 액세스 토큰보다 제한된 경로에서 사용되는가?
  4. 갱신 토큰이 회전되고 재사용을 탐지할 수 있는가?
  5. 여러 동시 요청이 하나의 갱신 작업을 안전하게 공유하는가?

어디에 저장하고 어떻게 전송하는가

  1. 브라우저 자바스크립트가 인증 정보에 접근해야 하는가?
  2. 쿠키에 HttpOnly, Secure, 적절한 SameSite가 설정되는가?
  3. 쿠키 기반 요청에 CSRF 방어가 필요한가?
  4. HTTPS가 아닌 연결로 인증 정보를 전송하지 않는가?
  5. URL, 로그, 분석 이벤트에 토큰이 노출되지 않는가?

철회할 수 있는가

  1. 현재 기기만 로그아웃할 수 있는가?
  2. 모든 기기의 인증 상태를 종료할 수 있는가?
  3. 비밀번호 변경이나 계정 정지 시 기존 상태를 철회하는가?
  4. 탈취된 인증 정보를 만료 전에도 차단해야 하는가?
  5. 철회 여부를 확인하기 위한 비용을 감당할 수 있는가?

권한 변경을 언제 반영하는가

  1. 토큰에 역할과 권한을 넣어야 하는가?
  2. 해당 정보가 바뀌면 기존 토큰은 어떻게 되는가?
  3. 민감한 작업은 현재 권한을 다시 조회하는가?
  4. 짧은 만료와 즉시 철회 중 어느 수준이 필요한가?
  5. 조직별 권한을 하나의 전역 역할로 단순화하고 있지 않은가?

여러 기기와 공격 상황을 고려했는가

  1. 기기별 세션을 독립적으로 관리하는가?
  2. 사용자가 최근 로그인 기기를 확인할 수 있는가?
  3. 새로운 기기나 의심스러운 갱신을 기록하는가?
  4. 세션 ID와 토큰 원문을 로그에 남기지 않는가?
  5. 탈취가 의심될 때 토큰 계열 전체를 철회할 수 있는가?

실패 후 클라이언트가 올바르게 행동하는가

  1. 만료와 유효하지 않은 인증 정보를 구분하는가?
  2. 갱신 실패 후 무한 재시도가 발생하지 않는가?
  3. 인증 실패와 권한 부족을 구분하는가?
  4. 재인증이 필요한 민감한 작업을 안내하는가?
  5. 원래 요청을 재실행해도 중복 작업이 발생하지 않는가?

흔한 실수는 세션과 토큰을 사용자 정보를 담는 상자로 보는 것이다

요청 본문의 사용자 ID를 신뢰한다

사용자는 다른 사람의 ID를 직접 전달할 수 있다.

현재 사용자 ID는 검증된 인증 상태에서 가져와야 한다.

토큰을 디코딩하기만 하고 검증하지 않는다

토큰 본문을 읽을 수 있다는 사실은 서명, 발급자, 대상, 만료가 유효하다는 뜻이 아니다.

필요한 모든 인증 조건을 검증한 뒤 주장을 사용해야 한다.

토큰에 민감한 정보를 넣는다

서명된 토큰의 내용이 자동으로 암호화되는 것은 아니다.

클라이언트와 네트워크 경계를 넘어도 되는 최소한의 주장만 포함해야 한다.

액세스 토큰을 지나치게 오래 유지한다

탈취된 토큰을 장기간 사용할 수 있고 즉시 철회하기 어려워진다.

짧은 액세스 수명과 제한된 갱신 상태를 고려해야 한다.

갱신 토큰을 일반 API 요청에 사용한다

장기 인증 정보가 자주 전송되어 노출 범위가 커진다.

갱신 토큰은 새 액세스 토큰을 발급하는 제한된 경로에서만 사용해야 한다.

브라우저 저장소 선택만으로 보안이 완성된다고 생각한다

쿠키는 CSRF를, 자바스크립트 저장소는 XSS를 고려해야 한다.

저장 위치와 함께 콘텐츠 보안, 요청 검증, HTTPS, 만료, 철회를 설계해야 한다.

역할을 토큰에 넣으면 권한 조회가 필요 없다고 생각한다

토큰의 역할은 발급 시점의 값일 수 있다.

권한 변경을 언제 반영해야 하는지에 따라 재조회, 버전 확인, 짧은 만료 정책이 필요하다.

로그아웃할 때 클라이언트 값만 삭제한다

복사된 토큰이나 서버의 갱신 상태는 계속 사용될 수 있다.

로그아웃은 필요한 범위의 서버 인증 상태까지 철회해야 한다.


세션과 토큰의 핵심은 요청 사이에서 신뢰의 생명주기를 관리하는 데 있다

프로그래밍을 처음 배울 때는 세션과 토큰을 로그인 정보를 저장하는 방법이라고 이해해도 충분하다.

const currentUserId =
  verifyAuthentication(request);

하지만 실제 서비스에서는 사용자 ID를 어디에 넣는지보다 신뢰를 어떻게 관리하는지가 중요하다.

  • 독립적인 요청을 같은 인증된 사용자와 어떻게 연결하는가?
  • 인증 상태의 Source of Truth는 어디에 있는가?
  • 세션과 토큰에는 어떤 정보만 포함해야 하는가?
  • 인증 상태는 언제 만료되고 어떻게 갱신되는가?
  • 탈취된 인증 정보의 피해 범위를 어떻게 제한하는가?
  • 브라우저와 모바일에서 어디에 안전하게 저장하는가?
  • 로그아웃과 보안 변경 시 어떤 상태를 철회하는가?
  • 여러 기기의 인증 상태를 각각 관리할 수 있는가?
  • 토큰의 권한 정보가 오래되었을 때 어떻게 처리하는가?
  • 인증 실패 후 클라이언트가 안전하게 복구하는가?

세션과 토큰은 로그인 정보를 저장하는 방법이 아니다.

세션과 토큰은 한 번 확인한 사용자 신원을 여러 요청에서 다시 증명할 수 있도록 전달하면서, 그 신뢰를 만료·갱신·철회하는 인증 상태의 생명주기를 설계하는 방법이다.

profile
Vision eXperience Developer

0개의 댓글