November 17, 2021
쿠키와 웹 스토리지(로컬 스토리지, 세션 스토리지)의 차이점, 쿠키의 옵션, 쿠키와 세션의 차이점, 세션 기반 인증 방식과 토큰 기반 인증 방식의 차이점을 알아보겠습니다.
이 시리즈는 총 4개의 글에 나눠 작성합니다.
쿠키는 프론트엔드에서 매우 중요한 만큼 3개의 글에 등장합니다.
쿠키는 매우 중요한 만큼 3개의 글에 걸쳐 등장합니다.
Keyplus를 개발하면서도 JWT를 저장하는 데에 쿠키를 사용하였으나 제대로 알고 사용하지 않았습니다. 이번 기회에 제대로 알아보려 합니다.
인증
이란 서비스로부터 일정 권한을 부여받은 사용자임을 인증받는 절차를 말합니다. 즉, 가입된 계정으로 로그인하는 절차입니다.
인가
란 이미 인증받은, 즉 로그인을 한 사용자가 서비스의 특정 기능을 사용하기 위한 권한을 허가받는 절차를 말합니다. 즉, 특정 기능을 사용하려 할 때 사용자가 이미 로그인을 한 상태인지, 그렇다면 그 기능을 사용할 권한이 있는지 검증하는 절차입니다.
서버가 인증 과정을 처리하는 방식, 즉 로그인을 구현하는 방식 두 가지를 알아보겠습니다. 하나는 로그인된 사용자의 정보를 서버 단에 저장하는 세션 기반 인증
이고, 다른 하나는 클라이언트 단에 저장하는 토큰 기반 인증
입니다.
사용자가 아이디와 비밀번호를 입력하여 로그인을 요청하면 서버는 그 정보를 데이터베이스의 정보와 비교하여 올바른 계정인지 검사합니다.(=인증) 만약 올바른 계정이라면 (메모리 혹은 데이터베이스 등의 저장 공간에) 세션이 생성되고 그 세션 안에 해당 사용자의 정보가 저장됩니다. 그러고 나서 해당 세션의 ID를 클라이언트에게 응답하여 세션 쿠키로 저장하게 합니다. 그러면 그 이후부터는 클라이언트가 요청을 보낼 때마다 해당 세션 ID가 쿠키로 서버에 전송됩니다. 이를 통해 서버는 요청을 보낸 사용자가 누구인지 식별할 수 있게 되어(=인가) 로그인 상태가 유지됩니다. 이후 만약 사용자가 로그아웃을 요청하면 서버는 해당 세션을 삭제합니다.
세션 기반 인증에서는 로그인한 사용자의 상태 정보를 서버 단의 저장소(메모리 혹은 데이터베이스)에 세션의 형태로 저장합니다. 그리고 클라이언트가 세션 ID를 쿠키로 서버에 전송하면 서버는 해당 세션 ID를 이용하여 적절한 세션을 탐색함으로써 해당 사용자가 이미 로그인을 한 상태인지, 그렇다면 요청된 기능을 사용할 권한이 있는지 검증합니다.
세션 기반 인증의 가장 큰 장점은 로그인한 사용자의 상태 정보를 서버 단에 저장하기에 상대적으로 안전하다는 것입니다. 또, 로그인한 사용자의 상태 정보를 서버 단에서 유연하게 관리 가능하다는 것도 장점입니다. 만약 누군가의 세션 ID가 탈취되었다면 서버는 해당 세션을 폭파하기만 하면 됩니다. 그러면 더 이상 탈취된 세션 ID로는 아무 것도 할 수 없게 됩니다.
세션 기반 인증의 단점으로는 우선 로그인한 사용자의 상태 정보를 서버 단에 저장하기 때문에 여러 사용자들이 동시에 많이 접속하면 오버헤드가 심해질 수 있다는 것이 있습니다. 또, 세션을 저장하는 저장소에 문제가 생겨서 세션이 전부 날아가면 로그인한 사용자들은 전부 다시 로그인을 해야 한다는 단점도 있습니다. 그리고 여러 대의 서버로 운영되는 웹 사이트의 경우 세션을 반드시 공용 저장소에 저장해야 한다는 것도 단점 중 하나입니다. 서버별로 존재하는 저장소에 저장하면 한 서버에서 로그인을 해도 다른 서버에서는 로그인되지 않은 상태로 인식하기 때문입니다.
사용자가 아이디와 비밀번호를 입력하여 로그인을 요청하면 그 정보를 데이터베이스의 정보와 비교하여 올바른 계정인지 검사합니다.(=인증) 만약 올바른 계정이라면 해당 사용자의 정보와 서버의 비밀 키를 이용하여 토큰을 발급하고 이를 클라이언트에게 응답합니다. 그러면 클라이언트는 해당 토큰을 쿠키, 로컬 스토리지, 세션 스토리지 중 하나에 저장합니다. 이후 클라이언트는 권한이 필요한 요청을 보낼 때마다 해당 토큰을 서버에 전송합니다. 이를 통해 서버는 그 토큰을 보고 해당 사용자가 누구인지 식별할 수 있게 되어(=인가) 로그인 상태가 유지됩니다. 이후 만약 사용자가 로그아웃을 요청하면 저장소(쿠키, 로컬 스토리지, 세션 스토리지 중 하나)에서 해당 토큰을 삭제합니다. 쿠키는 서버가 HTTP 헤더를 통해 삭제할 수 있고(만료 기한 설정 통해), 로컬 스토리지, 세션 스토리지는 서버가 HTTP 헤더를 통해 조작 불가능하므로 클라이언트에서 삭제해주어야 합니다. (토큰 방식은 서버 쪽에서 로그아웃할 수 없습니다.(토큰을 삭제할 수 없습니다.) 그래서 토큰의 주기를 짧게 하거나 또는 로그아웃 요청 시 해당 토큰을 더 이상 어디에서도 사용할 수 없도록 서버의 데이터베이스에 기록해두어 관리할 필요가 있습니다.(블랙 리스트 관리 느낌) (혹은 토큰을 DB에 보존하고 매 요청시 마다 로그아웃으로 인해 DB에서 삭제된 토큰이 아닌지를 검사하는 방법도 있습니다.)
토큰 기반 인증에서는 로그인한 사용자의 상태 정보를 클라이언트 단의 저장소(쿠키, 로컬 스토리지, 세션 스토리지)에 토큰의 형태로 저장합니다. 그리고 클라이언트가 토큰을 서버에 전송하면 서버는 해당 토큰에 담긴 정보를 이용하여 해당 사용자가 이미 로그인을 한 상태인지, 그렇다면 요청된 기능을 사용할 권한이 있는지 검증합니다.
토큰 기반 인증의 장점은 로그인한 사용자의 상태 정보를 애초에 서버 단에 저장하지 않기 때문에 서버 단 저장소의 오버헤드를 신경 쓸 필요가 없다는 점입니다. 또, 서버 확장에도 유리합니다.
토큰 기반 인증의 단점은 서버를 대신해서 클라이언트가 로그인한 사용자의 상태 정보를 기억하는 것이기 때문에 토큰 자체에 로그인한 사용자의 상태 정보가 담겨 있어 상대적으로 보안 위협이 있다는 점입니다. 또, 세션 기반 인증과 달리 서버가 이미 한 번 발급한 토큰을 무효화시킬 방법이 없기 때문에 탈취된 토큰은 그 유효 기간이 지나기 전까지 마음껏 사용할 수 있다는 점도 단점입니다.
토큰은 쿠키, 로컬 스토리지, 세션 스토리지 중 보통 쿠키나 로컬 스토리지에 저장합니다.
클라이언트 단에 저장되는 만큼 토큰은 탈취되기가 쉽습니다. 따라서 보안을 더욱 강화하는 것이 중요한데 다행히도 쿠키는 보안을 위한 몇몇 옵션들을 설정하는 것이 가능합니다. 옵션 관련하여 자세한 설명은 쿠키 파헤치기에서 확인 가능합니다.
XSS 공격에 의해 쿠키가 탈취당할 수도 있으므로 HttpOnly 옵션을 설정하여 JavaScript로는 해당 쿠키에 접근할 수 없도록 할 수 있습니다. 또, SameSite 옵션을 Lax 혹은 Strict로 설정하여 CSRF 공격에 의해 의도치 않게 쿠키가 전송되는 것을 막을 수 있습니다. 또, Secure 옵션도 설정하여 HTTPS 프로토콜을 사용할 때만 쿠키가 전송되도록 할 수 있습니다.
쿠키가 아닌 로컬 스토리지에 토큰을 저장하는 경우, 쿠키와 달리 매 요청마다 서버에 자동으로 전송되는 것이 아니기 때문에 CSRF 공격으로부터 상대적으로 안전합니다. 그리고 웹 스토리지의 데이터를 서버에 전송하려면 일단 웹 스토리지는 origin별로 독립되어 있으므로 A 도메인에서는 B 도메인의 웹 스토리지에 접근할 수 없기도 합니다.
다만, HttpOnly 옵션으로 XSS 공격을 방지할 수 있는 쿠키와 달리 웹 스토리지는 그러한 보안 옵션들을 설정할 수 없습니다. 따라서 이스케이프 처리 등으로 XSS 공격에 대한 대응을 면밀히 해줄 필요가 있습니다. XSS 취약점이 발생하면 동일한 도메인 내에서 CSRF 공격까지 발생할 수도 있기 때문에 더욱 주의해야 합니다. 한 번 설정해주면 그만인 쿠키에 비해서 신경 써줄 부분이 많다는 단점이 있습니다.
인증 완료 후 서버가 클라이언트에게 응답하는 세션 ID 혹은 토큰은 로그인 상태 유지를 위한 수단입니다. 즉, 이미 한 번 인증이 완료되었다면 매번 아이디와 비밀번호를 다시 입력할 필요가 없도록 하는 것입니다.
JWT
는 토큰 기반 인증
에서 사용하는 대표적인 토큰입니다. JWT는 헤더(Header), 페이로드(Payload), 서명(Signature)의 세 부분으로 이루어져 있습니다. 각 부분을 Base64로 인코딩하고 이들을 마침표(.)를 이용하여 차례대로 연결한 것이 JWT입니다.
헤더는 총 2가지 정보를 갖는 JSON 값입니다. 하나는 토크의 타입을 지정하는 type, 다른 하나는 서명 계산 및 토큰 검증에 사용할 암호화 알고리즘을 지정하는 alg입니다. JWT의 경우 type은 무조건 ‘JWT’이고, alg는 HS256(SHA256) 혹은 RSA 등으로 지정됩니다.
페이로드는 로그인한 사용자의 상태 정보를 갖는 JSON 값입니다. 누가 누구에게 발급한 토큰인지, 유효기간은 언제까지인지, 서비스가 이 토큰을 통해 사용자에게 공개하고자 하는 정보들은 무엇인지 등이 담깁니다. 서버는 이 토큰 자체로부터 사용자의 상태 정보를 얻을 수 있기 때문에 불필요한 데이터베이스 쿼리 등을 최소화할 수 있습니다.
헤더와 페이로드를 Base64로 인코딩하고, 이 둘을 마침표(.)로 연결한 것을 서버의 비밀 키와 함께 (헤더의 alg에 지정된) 암호화 알고리즘에 넣어서 돌리면 서명을 얻을 수 있습니다. 암호화 알고리즘 특성상, 헤더나 페이로드가 아주 조금만 바뀌어도 서명은 크게 달라집니다. 서명은 토큰의 유효성을 검증하는 데 사용됩니다.
만약 해커가 페이로드의 일부를 수정한 토큰을 서버에 전송한다고 가정해봅시다. (e.g. 사용자의 관리자 여부를 True로 바꿈) 페이로드가 조금이라도 바뀌는 경우, 반드시 서명도 다시 계산해서 바꿔줘야 합니다. 그렇지 않으면 위와 같은 절차에 의해 서버한테 요청이 거부당하기 때문입니다. 그러나 서명의 계산을 위해서는 서버의 비밀 키를 알아야 합니다. 그런데 서명 계산을 위한 암호화 알고리즘은 일방향 암호화이기 때문에 서명을 통해 역으로 서버의 비밀 키를 알아내는 것은 거의 불가능합니다. 즉, 해커는 서버의 비밀 키를 알 수 없고, 따라서 서명을 다시 계산하는 것은 사실상 불가능합니다.
결론적으로 서버는 토큰의 유효성 검증을 위해 오직 비밀 키만 안전하게 관리하면 됩니다.
지금까지 설명한 내용만 보면 JWT 중심 토큰 기반 인증 방식이 세션 기반 인증 방식에 비해 월등히 좋아 보입니다. 그러나 앞서 토큰 기반 인증도 한계가 있습니다. 크게 2가지 문제점이 존재합니다.
첫 째로는 JWT가 상대적으로 보안이 취약한 클라이언트 단에 저장이 된다는 것입니다. 그러면서 JWT 자체적으로 사용자의 상태 정보를 갖는다는 점입니다. 따라서 JWT의 페이로드를 Base64로 디코딩만 하면 해당 사용자의 정보를 손쉽게 얻어낼 수 있습니다. 따라서 JWT의 페이로드에는 필요한 최소한의 정보만을 담고, 지나치게 민감한 개인정보 등은 담지 않아야 합니다.
둘 째로는 이미 발급된 JWT를 무효화하기 어렵다는 것입니다. 세션 ID와 달리 한 번 발급한 JWT를 서버가 무효화할 방법은 없습니다. 굳이 방법을 떠올려 보자면, 무효화할 JWT의 목록을 블랙 리스트와 같은 형태로 데이터베이스에서 별도로 관리해줘야 합니다. 하지만 이는 매번 데이터베이스 쿼리를 필요로 한다는 점에서 비효율적입니다. 결국, 서버가 한 번 발급한 JWT의 경우 그 유효기간이 지나기 전까지는 유효한 토큰으로 판단한다는 문제가 발생합니다. 따라서 한 번 JWT가 탈취당하면 해커는 그 JWT의 유효기간이 지나기 전까지 마음껏 악의적인 동작을 수행할 수 있습니다.
위에서 설명한 JWT의 한계를 어느 정도 극복하는 방법으로 리프레시 토큰(Refresh Token)이 존재합니다.
리프레시 토큰을 사용한다는 것은 서버가 인증 완료 시 다음과 같은 두 종류의 토큰을 발급한다는 것을 의미합니다.
액세스 토큰은 기본적으로 유효기간을 굉장히 짧게 설정합니다. 이때 만약 리프레시 토큰이 없다면 로그인을 한 지 얼마 지나지 않아 유효기간이 지나 다시 로그인을 해야 하는 상황이 반복될 것입니다. 그러나 리프레시 토큰이 있다면 액세스 토큰의 유효기간이 지나더라도 리프레시 토큰을 서버에 전송하면 즉시 액세스 토큰을 재발급받을 수 있습니다. 따라서 다시 로그인을 할 필요가 없습니다. 이를 위해 일반적으로 리프레시 토큰은 액세스 토큰보다 유효기간을 훨씬 길게 설정합니다. 만약 리프레시 토큰의 유효기간마저 지난다면 그때는 정말로 로그인을 다시 해야 합니다. 즉, 실제 로그인 상태 유지 기간은 리프레시 토큰의 유효기간이 결정합니다.
리프레시 토큰은 인증 완료 시 발급되어 서버 단의 데이터베이스에 사용자별로 저장됩니다. (물론 액세스 토큰처럼 클라이언트 단에도 저장됩니다.) 만약 액세스 토큰이 만료되어 리프레시 토큰을 서버에게 전송하면, 서버는 해당 사용자의 리프레시 토큰을 데이터베이스에서 꺼내온 후 클라이언트가 전송한 것과 일치하는지 검사합니다. (액세스 토큰의 페이로드를 통해 어떤 사용자인지 식별 가능합니다.) 만약 일치한다면 새로운 액세스 토큰을 발급해줍니다.