Skip to content

Split View: JWT와 세션, 무엇을 언제 — 상태를 어디에 둘 것인가로 정리하는 인증 선택

✨ Learn with Quiz
|

JWT와 세션, 무엇을 언제 — 상태를 어디에 둘 것인가로 정리하는 인증 선택

들어가며 — JWT가 확장성이 좋다는 말의 실제 의미

인증 방식을 정할 때 가장 자주 등장하는 문장이 있습니다. "세션은 서버에 상태를 두니까 확장이 안 되고, JWT는 무상태라서 확장이 잘된다." 이 문장은 절반만 맞습니다. 그리고 틀린 절반이 실무에서 훨씬 비싼 대가를 치릅니다.

우선 사실 확인부터 합니다. JWT는 암호화되지 않습니다. base64url로 인코딩됐을 뿐이라 누구나 읽습니다.

echo 'eyJzdWIiOiJ1c2VyXzg4MTIiLCJyb2xlIjoiYWRtaW4iLCJlbWFpbCI6ImtpbUBleGFtcGxlLmNvbSIsImV4cCI6MTc4NDA1NjAwMH0' \
  | base64 -d
{"sub":"user_8812","role":"admin","email":"kim@example.com","exp":1784056000}

서명이 보장하는 것은 위조 불가이지 비밀 유지가 아닙니다. 그래서 페이로드에 이메일, 전화번호, 내부 식별자를 넣는 것은 브라우저 로컬 스토리지에 평문으로 개인정보를 저장하는 것과 같습니다. JWE로 암호화하지 않는 한 페이로드는 공개 데이터라고 생각해야 합니다.

이 글은 두 방식 중 하나를 고르는 기준을 다룹니다. 결론을 미리 말하면, 브라우저를 상대하는 대부분의 웹 애플리케이션에는 세션 쿠키가 더 낫고, JWT는 서비스 간 통신과 무상태가 정말로 필요한 자리에 씁니다.

근본 차이는 하나다 — 상태를 어디에 두는가

세션 방식에서 서버는 세션 저장소에 인증 상태를 들고 있고, 클라이언트는 의미 없는 식별자만 쿠키로 들고 있습니다. 요청이 오면 서버는 그 식별자로 저장소를 조회합니다. 조회 결과가 없으면 인증 실패입니다.

JWT 방식에서 서버는 아무것도 들고 있지 않고, 클라이언트가 서명된 클레임 자체를 들고 있습니다. 요청이 오면 서버는 서명을 검증하고 만료 시각을 확인합니다. 저장소 조회가 없습니다.

여기서 모든 것이 갈라집니다. 세션은 인증 판단의 근거가 서버에 있으니 서버가 언제든 바꿀 수 있습니다. JWT는 근거가 이미 클라이언트 손에 나가 있으니 서버가 회수할 수 없습니다. 성능도 저장 위치도 이 차이의 결과일 뿐입니다.

항목세션 쿠키JWT (무상태)짧은 액세스 토큰 + 리프레시
상태 위치서버 저장소클라이언트클라이언트 + 리프레시용 서버 저장소
요청당 비용저장소 조회 1회 (Redis 기준 1ms 미만)서명 검증 (HMAC은 마이크로초 단위)서명 검증. 갱신 주기마다만 저장소 조회
즉시 무효화가능. 레코드 삭제불가능. 만료까지 유효액세스 토큰 수명만큼 지연 (보통 5분에서 15분)
권한 변경 반영즉시만료 후갱신 시점
크기32바이트 내외의 식별자300에서 1000바이트. 매 요청 전송액세스 토큰 크기 동일
주요 노출 경로CSRFXSS (저장 위치에 따라)저장 설계에 따라 달라짐
다중 노드 확장공유 저장소 필요공유 저장소 불필요갱신 경로에만 공유 저장소 필요
검증 주체서버 본인비밀키나 공개키를 가진 누구나액세스 토큰은 누구나, 갱신은 발급자만

표의 마지막 줄이 JWT의 진짜 존재 이유입니다. 세션 식별자는 저장소에 접근할 수 있는 주체만 해석할 수 있지만, JWT는 공개키만 가지면 누구나 검증할 수 있습니다. 발급자와 검증자가 다른 조직이거나 다른 시스템일 때, 이 성질은 대체 불가능합니다.

JWT의 최대 약점 — 즉시 무효화가 불가능하다

계정이 탈취되어 사용자가 비밀번호를 바꿨다고 합시다. 세션이면 그 사용자의 세션 레코드를 전부 지우면 끝입니다. 다음 요청부터 즉시 로그아웃됩니다.

JWT면 지울 대상이 없습니다. 공격자가 들고 있는 토큰은 만료 시각까지 유효합니다. 만료를 24시간으로 잡았다면 최대 24시간 동안 공격자는 계속 로그인 상태입니다. 관리자 권한을 회수해도, 계정을 정지시켜도, 결제를 막아도 마찬가지입니다. 서버가 검증하는 것은 서명과 exp뿐이기 때문입니다.

현실적인 대응은 세 가지가 조합됩니다.

첫째, 액세스 토큰 수명을 짧게 잡습니다. 5분에서 15분이 일반적입니다. 노출 창을 그만큼 줄이는 방법입니다.

둘째, 리프레시 토큰으로 갱신합니다. 리프레시 토큰은 서버 저장소에 기록되어 있고, 갱신 시점에 유효성을 확인합니다. 회전을 함께 씁니다. 한 번 쓴 리프레시 토큰은 즉시 폐기하고 새것을 발급하며, 이미 쓴 토큰이 다시 들어오면 탈취로 간주해 그 계열 전체를 폐기합니다.

async function rotateRefreshToken(presented) {
  const record = await db.refreshTokens.findByHash(sha256(presented))
  if (!record) throw new AuthError('unknown token')

  if (record.usedAt) {
    // 이미 사용된 토큰이 다시 왔다 = 사본이 돌아다닌다
    await db.refreshTokens.revokeFamily(record.familyId)
    throw new AuthError('reuse detected, family revoked')
  }

  await db.refreshTokens.markUsed(record.id)
  return issuePair({ userId: record.userId, familyId: record.familyId })
}

셋째, 블랙리스트를 둡니다. 무효화된 토큰의 jti를 저장해 두고 매 요청마다 대조합니다.

그런데 여기서 정직하게 짚어야 할 지점이 있습니다. 블랙리스트를 두는 순간 모든 요청이 공유 저장소를 조회하게 되고, 그것은 세션과 정확히 같은 구조입니다. 저장소 장애가 인증 장애가 되는 것도, 노드마다 저장소에 붙어야 하는 것도 같습니다. 다른 점은 세션보다 부품이 많고 토큰이 크다는 것뿐입니다. 무상태의 이점을 없애면서 복잡도만 남기는 선택이 됩니다.

그래서 실제로 쓸 만한 중간 지점은 짧은 액세스 토큰과 저장된 리프레시 토큰의 조합입니다. 저장소 조회를 매 요청이 아니라 갱신 주기마다 한 번으로 줄이면서, 무효화 지연을 액세스 토큰 수명 이내로 묶습니다. 이 설계를 고를 때는 그 지연이 비즈니스적으로 감당 가능한지를 먼저 답해야 합니다. 금융 거래 권한처럼 초 단위 회수가 필요한 자리라면 감당할 수 없습니다.

토큰을 어디에 저장할 것인가

이 질문은 XSS와 CSRF 중 어느 쪽을 감수할 것인가로 요약되곤 하는데, 정확한 비교는 조금 다릅니다.

localStorage에 넣으면 같은 오리진의 모든 스크립트가 읽습니다. XSS가 한 번 발생하면 토큰이 통째로 공격자 서버로 전송되고, 공격자는 그 토큰으로 브라우저 밖에서 자유롭게 API를 호출합니다. 만료까지 유효하고, 사용자가 로그아웃해도 소용없습니다. XSS 하나가 곧 자격 증명 유출입니다.

httpOnly 쿠키에 넣으면 스크립트가 값을 읽을 수 없습니다. XSS가 나도 토큰 자체는 빠져나가지 않습니다. 다만 공격자 스크립트가 그 페이지 안에서 요청을 날리면 쿠키가 자동으로 실리므로 사용자 대신 행동할 수는 있습니다. 이것은 분명한 피해지만, 자격 증명이 유출되어 임의의 위치에서 무기한 사용되는 것과는 피해 규모가 다릅니다. 공격 창이 그 세션과 그 페이지로 제한됩니다.

쿠키를 쓰면 CSRF를 처리해야 합니다. 속성 설정이 1차 방어입니다.

Set-Cookie: sid=8f3c...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600

SameSite=Lax는 교차 사이트 POST에 쿠키를 싣지 않고, 최상위 내비게이션 GET에만 싣습니다. 크로미움이 기본값으로 채택하면서 폼 기반 CSRF의 상당 부분이 자동으로 막혔습니다. Strict는 외부 링크를 타고 들어온 GET에도 쿠키를 안 싣기 때문에 로그인 상태가 풀린 것처럼 보입니다. None은 교차 사이트 전송을 허용하며 Secure가 필수인데, 이 경우 CSRF 토큰이 반드시 필요합니다.

여기에 중요한 함정이 있습니다. SameSite는 오리진이 아니라 사이트 단위로 판단합니다. app.example.com과 api.example.com은 다른 오리진이지만 같은 사이트입니다. 서브도메인 하나에 XSS가 생기면 SameSite는 아무 방어도 하지 않습니다. 사용자 콘텐츠를 서브도메인에 호스팅하는 서비스라면 특히 위험합니다.

SPA에서 널리 쓰이는 절충안은 액세스 토큰을 자바스크립트 메모리 변수에만 두고, 리프레시 토큰을 httpOnly 쿠키로 갱신 경로에만 스코프하는 것입니다.

// 액세스 토큰은 모듈 스코프 변수에만. 새로고침하면 사라집니다
let accessToken = null

async function refresh() {
  // 리프레시 쿠키는 Path=/auth/refresh 로 스코프되어 이 요청에만 실립니다
  const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
  if (!res.ok) throw new AuthError('refresh failed')
  accessToken = (await res.json()).access_token
}

새로고침할 때마다 갱신 요청이 한 번 나가는 대신, XSS가 나도 지속 가능한 자격 증명은 스크립트 손에 들어가지 않습니다.

한 가지 덧붙이면, XSS가 발생한 상황에서 완전한 방어는 없습니다. 저장 위치 논쟁은 피해를 줄이는 이야기이지 막는 이야기가 아닙니다. Content-Security-Policy와 출력 이스케이프가 근본 대책이고, 저장 위치는 그다음입니다.

구현 취약점 — 서명은 검증할 때만 의미가 있다

JWT는 스펙이 유연해서 라이브러리 사용법을 조금만 어긋나게 쓰면 서명이 무력화됩니다. 고전이 된 사례들입니다.

alg 헤더를 none으로 바꿔 보내면 서명 없이 통과되던 라이브러리들이 있었습니다. 공격자는 페이로드의 role을 admin으로 바꾸고 서명 부분을 비운 토큰을 만들면 그만이었습니다. 오래된 이야기 같지만, 검증 시 알고리즘을 토큰 헤더에서 읽어 오는 코드는 지금도 나옵니다.

알고리즘 혼동은 더 교묘합니다. RS256으로 발급하는 서버에서 검증 함수가 알고리즘을 고정하지 않으면, 공격자가 HS256으로 헤더를 바꾸고 서버의 공개키를 HMAC 비밀키로 써서 서명한 토큰을 만듭니다. 공개키는 이름 그대로 공개되어 있으므로 누구나 만들 수 있습니다.

가장 흔한 것은 검증을 아예 안 하는 실수입니다.

// 하지 마세요 — decode는 서명을 검증하지 않습니다
const payload = jwt.decode(token)
if (payload.role === 'admin') grantAdmin()
// 검증 시 알고리즘, 발급자, 대상을 서버가 고정합니다
import { jwtVerify } from 'jose'

const { payload } = await jwtVerify(token, publicKey, {
  algorithms: ['RS256'], // 토큰이 고르게 두지 않습니다
  issuer: 'https://auth.example.com',
  audience: 'https://api.example.com',
  clockTolerance: 5,
})

aud를 검증하지 않으면 다른 서비스용으로 발급된 토큰이 우리 API에서 통과합니다. 같은 인증 서버를 쓰는 내부 서비스가 여럿이면 실제로 벌어지는 일입니다. iss 미검증도 같은 성격입니다.

HMAC을 쓸 때 비밀키가 짧으면 오프라인에서 무차별 대입으로 뚫립니다. 사전 단어나 기본값을 그대로 둔 사례가 공개 서비스에서도 발견됩니다. 최소 256비트의 무작위 값을 쓰고, 코드나 저장소가 아니라 시크릿 관리 시스템에 둡니다.

kid 헤더로 키를 찾는 구현은 그 값이 공격자 입력이라는 점을 잊으면 안 됩니다. 파일 경로나 SQL 조회에 그대로 넣으면 경로 조작과 주입이 생깁니다. 알려진 키 식별자 집합과 대조한 뒤에만 사용합니다.

세션이 실제로 확장에 문제가 되는 시점

세션이 확장에 불리하다는 통념을 숫자로 확인해 보겠습니다.

세션 레코드 하나는 사용자 식별자, 발급 시각, 만료, 약간의 메타데이터로 이루어집니다. 넉넉히 잡아 200바이트에서 500바이트입니다. 동시 세션 100만 개면 200MB에서 500MB입니다. 레디스 인스턴스 하나가 여유롭게 감당하는 크기입니다. 1000만 개라도 수 GB이고, 이 규모의 서비스라면 이미 레디스 클러스터를 운영하고 있을 것입니다.

조회 비용은 키 하나에 대한 O(1) 조회입니다. 같은 리전 내 레디스 왕복은 보통 1ms 미만이고, 단일 인스턴스가 초당 수만에서 십만 단위 명령을 처리합니다. 애플리케이션이 요청당 수행하는 다른 DB 쿼리들과 비교하면 무시할 만한 비중입니다.

그러면 세션이 실제로 문제가 되는 지점은 어디인가. 세 가지입니다.

프로세스 메모리에 세션을 두고 스티키 세션으로 붙여 놓은 경우입니다. 배포할 때마다 로그인이 풀리고, 노드 간 부하가 균등해지지 않습니다. 이것은 세션의 문제가 아니라 저장소 선택의 문제이고, 공유 저장소로 옮기면 해결됩니다.

리전이 여러 개인 경우입니다. 유럽 사용자의 요청이 세션 조회를 위해 미국 리전까지 왕복하면 수백 밀리초가 추가됩니다. 리전별 저장소와 복제 전략이 필요해집니다. 이 지점부터는 무상태 토큰이 실질적인 이점을 갖습니다.

검증 주체가 우리가 아닌 경우입니다. 파트너사가 우리 토큰을 검증해야 하는데 우리 세션 저장소를 열어 줄 수는 없습니다. 공개키 검증이 답입니다.

정리하면, 세션이 확장에 불리해지는 시점은 대부분의 팀이 생각하는 것보다 훨씬 늦게 옵니다. 그 시점이 오기 전에 JWT를 선택하면, 얻지 못한 확장성 대신 무효화 불가능이라는 부채만 먼저 떠안습니다.

실무 권고 — 무엇을 언제

같은 도메인에서 동작하는 웹 애플리케이션이라면 세션 쿠키가 기본값입니다. httpOnly, Secure, SameSite=Lax를 붙이고, 저장소는 레디스나 DB를 씁니다. 로그아웃과 권한 회수가 즉시 반영되고, 부품이 적고, 토큰 크기 걱정이 없습니다. 서브도메인 간 공유가 필요하면 Domain 속성으로 해결됩니다.

모바일 앱이나 서드파티 클라이언트가 붙는 자사 API라면 짧은 액세스 토큰과 회전하는 리프레시 토큰을 씁니다. 쿠키를 쓸 수 없는 환경이므로 토큰이 필요하고, 리프레시 경로에 상태를 두어 무효화 수단을 확보합니다. 리프레시 토큰은 OS의 보안 저장소에 넣습니다.

서비스 간 통신에는 JWT가 잘 맞습니다. 호출자마다 인증 서버로 왕복하지 않고 공개키로 검증할 수 있고, 스코프를 좁게 잡아 짧게 발급하면 무효화 문제가 실질적으로 사라집니다. 수명이 수십 초 단위라면 폐기할 이유 자체가 거의 없습니다.

외부 신원 제공자를 쓰는 경우, OIDC의 ID 토큰은 JWT입니다. 다만 ID 토큰은 신원을 증명하는 용도이지 API 접근 자격 증명이 아닙니다. ID 토큰을 그대로 API 인증에 쓰는 것은 흔한 오용입니다.

마지막으로 피해야 할 조합이 있습니다. 만료가 긴 JWT를 localStorage에 넣고 로그아웃은 클라이언트에서 값을 지우는 것으로 처리하는 설계입니다. 로그아웃 버튼이 아무것도 무효화하지 않고, XSS 한 번이면 장기 자격 증명이 통째로 나갑니다. 가장 널리 퍼진 튜토리얼 형태이면서 가장 취약한 구성입니다.

마치며 — 무상태가 공짜인지부터 확인한다

JWT를 고를 때 던져야 할 질문은 "확장이 잘될까"가 아니라 "이 자격 증명을 회수하지 못해도 괜찮은가"입니다. 괜찮다면 무상태의 이점을 온전히 누립니다. 괜찮지 않다면 어떤 형태로든 상태를 다시 들여와야 하고, 그 순간 세션 대비 우위는 대부분 사라집니다.

인증 설계의 첫 결정은 토큰 형식이 아니라 무효화 요구사항이고, 그것이 상태를 어디에 둘지를 결정합니다. 브라우저를 상대하는 평범한 웹 서비스라면 세션 쿠키로 시작하는 편이 거의 항상 옳습니다. 나중에 무상태가 진짜로 필요해졌을 때 옮기는 비용이, 처음부터 JWT로 시작해 무효화 사고를 겪는 비용보다 쌉니다.

JWT or Sessions, and When — Settling the Authentication Choice by Asking Where the State Lives

Introduction — what people actually mean when they say JWT scales well

There is a sentence that shows up more than any other when picking an authentication scheme. "Sessions keep state on the server so they do not scale; JWT is stateless so it scales well." That sentence is half right. And the wrong half costs far more in practice.

Start with a fact check. A JWT is not encrypted. It is merely base64url-encoded, so anyone can read it.

echo 'eyJzdWIiOiJ1c2VyXzg4MTIiLCJyb2xlIjoiYWRtaW4iLCJlbWFpbCI6ImtpbUBleGFtcGxlLmNvbSIsImV4cCI6MTc4NDA1NjAwMH0' \
  | base64 -d
{"sub":"user_8812","role":"admin","email":"kim@example.com","exp":1784056000}

What the signature guarantees is that the token cannot be forged, not that it is kept secret. So putting an email address, a phone number, or an internal identifier in the payload is equivalent to storing personal data in plaintext in browser local storage. Unless you encrypt with JWE, treat the payload as public data.

This post is about the criteria for choosing between the two. To state the conclusion up front: for most web applications that talk to a browser, session cookies are the better fit, and JWT belongs in service-to-service communication and in the places where statelessness is genuinely required.

There is one fundamental difference — where the state lives

In the session approach, the server holds authentication state in a session store and the client holds only a meaningless identifier in a cookie. When a request arrives, the server looks up the store with that identifier. No result means authentication failed.

In the JWT approach, the server holds nothing and the client holds the signed claims themselves. When a request arrives, the server verifies the signature and checks the expiry. There is no store lookup.

Everything diverges from here. With sessions, the basis for the authentication decision lives on the server, so the server can change it at any time. With JWT, the basis has already left for the client, so the server cannot take it back. Performance and storage location are merely consequences of that difference.

AspectSession cookieJWT (stateless)Short access token + refresh
State locationServer storeClientClient + server store for refresh
Cost per request1 store lookup (under 1ms with Redis)Signature verification (microseconds for HMAC)Signature verification. Store lookup only per renewal
Instant revocationPossible. Delete the recordImpossible. Valid until expiryDelayed by the access token lifetime (usually 5 to 15 minutes)
Permission change appliesImmediatelyAfter expiryAt renewal
SizeIdentifier of roughly 32 bytes300 to 1000 bytes. Sent on every requestSame as the access token size
Main exposure pathCSRFXSS (depending on where it is stored)Depends on the storage design
Multi-node scalingNeeds a shared storeNo shared store neededShared store needed only on the renewal path
Who can verifyThe server itselfAnyone holding the secret or the public keyAnyone for the access token, only the issuer for renewal

The last row of the table is the real reason JWT exists. A session identifier can be interpreted only by a party with access to the store, but a JWT can be verified by anyone who holds the public key. When the issuer and the verifier are different organizations or different systems, that property has no substitute.

The biggest weakness of JWT — instant revocation is impossible

Suppose an account was compromised and the user changed their password. With sessions, you delete every session record for that user and you are done. They are logged out from the very next request.

With JWT there is nothing to delete. The token the attacker holds is valid until its expiry. If you set expiry to 24 hours, the attacker stays logged in for up to 24 hours. Revoking admin rights, suspending the account, blocking payments — none of it matters. The server only verifies the signature and exp.

The realistic response is a combination of three things.

First, keep the access token lifetime short. Five to fifteen minutes is typical. This shrinks the exposure window by that much.

Second, renew with a refresh token. The refresh token is recorded in a server store and its validity is checked at renewal time. Use rotation alongside it. A refresh token that has been used once is discarded immediately and a new one issued, and if an already-used token comes back in, treat it as theft and revoke the entire family.

async function rotateRefreshToken(presented) {
  const record = await db.refreshTokens.findByHash(sha256(presented))
  if (!record) throw new AuthError('unknown token')

  if (record.usedAt) {
    // an already-used token came back = a copy is circulating
    await db.refreshTokens.revokeFamily(record.familyId)
    throw new AuthError('reuse detected, family revoked')
  }

  await db.refreshTokens.markUsed(record.id)
  return issuePair({ userId: record.userId, familyId: record.familyId })
}

Third, keep a blacklist. Store the jti of every revoked token and compare against it on every request.

But there is a point here that has to be made honestly. The moment you add a blacklist, every request hits a shared store, and that is exactly the same structure as sessions. A store outage becoming an authentication outage is the same, and every node needing to connect to the store is the same. The only differences are that there are more moving parts than with sessions and the tokens are larger. It becomes a choice that removes the benefit of statelessness and keeps only the complexity.

So the genuinely usable middle ground is the combination of a short access token and a stored refresh token. It reduces store lookups from every request to once per renewal cycle, while bounding the revocation delay to within the access token lifetime. When you pick this design, the first question to answer is whether that delay is acceptable to the business. In a place like financial transaction authority, where withdrawal has to happen in seconds, it is not.

Where to store the token

This question is usually reduced to a choice between accepting XSS or accepting CSRF, but the accurate comparison is a little different.

Put it in localStorage and every script on the same origin can read it. One XSS and the whole token is shipped to the attacker server, and the attacker can then call the API freely from outside the browser. It stays valid until expiry, and the user logging out changes nothing. One XSS is credential leakage.

Put it in an httpOnly cookie and scripts cannot read the value. Even with an XSS, the token itself does not get out. The attacker script can still fire requests from inside that page and the cookie rides along, so it can act on the user's behalf. That is real damage, but the scale of it differs from a credential leaking and being used indefinitely from anywhere. The attack window is limited to that session and that page.

If you use cookies, you have to handle CSRF. Setting the attributes is the first line of defence.

Set-Cookie: sid=8f3c...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600

SameSite=Lax does not attach the cookie to a cross-site POST; it attaches it only to top-level navigation GETs. When Chromium adopted it as the default, a large share of form-based CSRF was blocked automatically. Strict also withholds the cookie on a GET arriving through an external link, which makes it look like the user has been logged out. None permits cross-site sending and requires Secure, and in that case a CSRF token is mandatory.

There is an important trap here. SameSite judges by site, not by origin. app.example.com and api.example.com are different origins but the same site. If an XSS appears on one subdomain, SameSite provides no defence at all. This is especially dangerous for services that host user content on subdomains.

The compromise widely used in SPAs is to keep the access token only in a JavaScript memory variable and to scope the refresh token as an httpOnly cookie limited to the renewal path.

// the access token lives only in a module-scope variable. a reload loses it
let accessToken = null

async function refresh() {
  // the refresh cookie is scoped to Path=/auth/refresh so it rides only on this request
  const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
  if (!res.ok) throw new AuthError('refresh failed')
  accessToken = (await res.json()).access_token
}

The cost is one renewal request on every reload; the gain is that even with an XSS, no durable credential ever reaches the script.

One thing to add: in a situation where XSS has occurred, there is no complete defence. The storage-location debate is about reducing damage, not preventing it. Content-Security-Policy and output escaping are the fundamental measures, and storage location comes after them.

Implementation flaws — a signature only means something when it is verified

The JWT spec is flexible enough that using a library slightly wrong disables the signature entirely. Here are the cases that have become classics.

There were libraries that let a token through unsigned if you changed the alg header to none. The attacker only had to change role in the payload to admin and leave the signature part empty. It sounds like ancient history, but code that reads the algorithm out of the token header at verification time still turns up today.

Algorithm confusion is subtler. If the verification function on a server that issues RS256 tokens does not pin the algorithm, the attacker changes the header to HS256 and creates a token signed with the server's public key used as the HMAC secret. The public key is, by name, public, so anyone can produce one.

The most common of all is simply not verifying.

// do not do this — decode does not verify the signature
const payload = jwt.decode(token)
if (payload.role === 'admin') grantAdmin()
// at verification, the server pins the algorithm, issuer, and audience
import { jwtVerify } from 'jose'

const { payload } = await jwtVerify(token, publicKey, {
  algorithms: ['RS256'], // the token does not get to choose
  issuer: 'https://auth.example.com',
  audience: 'https://api.example.com',
  clockTolerance: 5,
})

If you do not verify aud, a token issued for a different service will pass at our API. When several internal services share the same authentication server, this actually happens. Not verifying iss is the same kind of problem.

When you use HMAC, a short secret can be brute-forced offline. Dictionary words and untouched default values have been found in public services. Use a random value of at least 256 bits, and keep it in a secret management system rather than in code or a repository.

An implementation that looks up keys by the kid header must not forget that the value is attacker input. Dropping it straight into a file path or a SQL lookup produces path traversal and injection. Use it only after comparing against a known set of key identifiers.

When sessions actually become a scaling problem

Let us check the received wisdom that sessions do not scale, using numbers.

A session record consists of a user identifier, an issue time, an expiry, and a little metadata. Generously, that is 200 to 500 bytes. One million concurrent sessions is 200MB to 500MB. That is a size a single Redis instance handles comfortably. Even ten million is a few gigabytes, and a service at that scale is already running a Redis cluster.

The lookup cost is an O(1) lookup on a single key. A Redis round trip within the same region is usually under 1ms, and a single instance processes tens of thousands to hundreds of thousands of commands per second. Compared with the other database queries the application runs per request, it is a negligible share.

So where do sessions actually become a problem? Three places.

When sessions live in process memory and clients are pinned with a sticky session. Every deploy logs everyone out, and load never balances evenly across nodes. That is not a problem with sessions but a problem with the choice of store, and moving to a shared store fixes it.

When there are multiple regions. If a European user's request has to round-trip to a US region for a session lookup, hundreds of milliseconds get added. You start needing per-region stores and a replication strategy. From this point, stateless tokens have a real advantage.

When the verifier is not us. A partner company has to verify our tokens, and we cannot open our session store to them. Public key verification is the answer.

In short, the point at which sessions become a scaling liability arrives much later than most teams think. Choosing JWT before that point means taking on the debt of impossible revocation first, in exchange for scalability you never actually needed.

Practical recommendations — what to use and when

For a web application running on the same domain, session cookies are the default. Attach httpOnly, Secure, and SameSite=Lax, and use Redis or a database for the store. Logout and permission revocation take effect immediately, there are fewer moving parts, and token size is a non-issue. If you need sharing across subdomains, the Domain attribute handles it.

For your own API with mobile apps or third-party clients attached, use a short access token and a rotating refresh token. Cookies are unavailable in that environment so you need tokens, and putting state on the refresh path gives you a means of revocation. Store the refresh token in the secure storage provided by the OS.

For service-to-service communication, JWT fits well. Each caller can verify with a public key instead of round-tripping to an authentication server, and if you keep the scope narrow and the lifetime short, the revocation problem effectively disappears. With a lifetime measured in tens of seconds, there is barely a reason to revoke anything.

If you use an external identity provider, the OIDC ID token is a JWT. But an ID token is meant to prove identity, not to serve as an API access credential. Using an ID token directly for API authentication is a common misuse.

Finally, there is one combination to avoid. A long-lived JWT in localStorage, with logout implemented as deleting the value on the client. The logout button revokes nothing, and a single XSS carries off a long-lived credential wholesale. It is the most widely spread tutorial shape and the most vulnerable configuration.

Closing — first check whether statelessness is free

The question to ask when choosing JWT is not "will this scale well?" but "is it acceptable that I cannot take this credential back?" If it is acceptable, you get the benefits of statelessness in full. If it is not, you will have to bring state back in some form, and at that moment most of the advantage over sessions disappears.

The first decision in authentication design is not the token format but the revocation requirement, and that requirement determines where the state goes. For an ordinary web service talking to a browser, starting with session cookies is almost always right. The cost of moving later, when statelessness genuinely becomes necessary, is cheaper than the cost of starting with JWT and living through a revocation incident.