프로그래밍을 처음 배울 때 세션과 토큰은 로그인한 사용자 정보를 저장하는 두 가지 방법이라고 배운다.
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();
}
토큰 회전은 요청마다 문자열을 바꾸는 기능이 아니다. 장기 인증 정보가 복제되었는지 발견하고 관련 인증 상태를 폐기할 수 있게 하는 생명주기 정책이다.
브라우저 애플리케이션에서 인증 토큰을 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(),
});
검증할 수 있는 항목에는 다음이 포함된다.
요청 헤더도 외부 입력이다. 외부 입력은 검증 전까지 신뢰할 수 없다.
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[인증 상태 철회]
각 단계는 서로 다른 책임을 가진다.
세션과 토큰을 설계한다는 것은 값을 하나 저장하고 읽는 일이 아니다. 신뢰가 만들어지고, 전달되고, 만료되고, 갱신되고, 철회되는 전체 흐름을 설계하는 일이다.
HttpOnly, Secure, 적절한 SameSite가 설정되는가?사용자는 다른 사람의 ID를 직접 전달할 수 있다.
현재 사용자 ID는 검증된 인증 상태에서 가져와야 한다.
토큰 본문을 읽을 수 있다는 사실은 서명, 발급자, 대상, 만료가 유효하다는 뜻이 아니다.
필요한 모든 인증 조건을 검증한 뒤 주장을 사용해야 한다.
서명된 토큰의 내용이 자동으로 암호화되는 것은 아니다.
클라이언트와 네트워크 경계를 넘어도 되는 최소한의 주장만 포함해야 한다.
탈취된 토큰을 장기간 사용할 수 있고 즉시 철회하기 어려워진다.
짧은 액세스 수명과 제한된 갱신 상태를 고려해야 한다.
장기 인증 정보가 자주 전송되어 노출 범위가 커진다.
갱신 토큰은 새 액세스 토큰을 발급하는 제한된 경로에서만 사용해야 한다.
쿠키는 CSRF를, 자바스크립트 저장소는 XSS를 고려해야 한다.
저장 위치와 함께 콘텐츠 보안, 요청 검증, HTTPS, 만료, 철회를 설계해야 한다.
토큰의 역할은 발급 시점의 값일 수 있다.
권한 변경을 언제 반영해야 하는지에 따라 재조회, 버전 확인, 짧은 만료 정책이 필요하다.
복사된 토큰이나 서버의 갱신 상태는 계속 사용될 수 있다.
로그아웃은 필요한 범위의 서버 인증 상태까지 철회해야 한다.
프로그래밍을 처음 배울 때는 세션과 토큰을 로그인 정보를 저장하는 방법이라고 이해해도 충분하다.
const currentUserId =
verifyAuthentication(request);
하지만 실제 서비스에서는 사용자 ID를 어디에 넣는지보다 신뢰를 어떻게 관리하는지가 중요하다.
세션과 토큰은 로그인 정보를 저장하는 방법이 아니다.
세션과 토큰은 한 번 확인한 사용자 신원을 여러 요청에서 다시 증명할 수 있도록 전달하면서, 그 신뢰를 만료·갱신·철회하는 인증 상태의 생명주기를 설계하는 방법이다.