인증
로그인한 사용자가 새로고침을 누르면 로그인 화면으로 돌아간다. 자격 증명은 문제없었다. 앱이 세션 존재 여부 확인을 마치기 전에 라우트 보호를 렌더링했고, 보호 장치가 없는 응답을 "로그아웃 상태"로 읽었다.
이 문제를 올바르게 해결하려면 **세션**이 무엇인지 정확히 이해해야 한다. 세션은 누군가 로그인했으며 그들의 신원을 담고 있는 상태이고, 한 곳에서 소유되어 모든 컴포넌트가 같은 답을 읽는다. 이 과정에서는 Supabase를 사용한 CRM 프로젝트를 통해 이를 구축하지만, Firebase, Auth0, Clerk, 또는 자신의 백엔드 등 어떤 인증 제공자를 사용하든 구조는 동일하다. 이번 장에서 그 구조를 다룬다.
세션 상태는 provider에서 관리합니다
사용자가 로그인했는지 여부는 모든 곳에서 중요하다. 라우터는 페이지를 보호하기 위해 필요하고, 헤더는 로그인한 사람을 표시하기 위해 필요하며, 폼은 데이터를 어디로 보낼지 알기 위해 필요하다. 이것이 정확히 context가 존재하는 이유이고, 표준적인 방식은 그 장의 provider-plus-hook 패턴이다. AuthProvider는 세션 상태를 소유하고, useAuth hook은 요청하는 모든 컴포넌트에 전달한다.
세션 상태는 세 가지 의미 있는 값을 가지며, 세 번째가 사람들이 놓치는 것이다.
undefined: 확인이 아직 완료되지 않았다. 앱이 로드되었고 provider에 세션 존재 여부를 묻는 중이다.null: 확인이 완료되었고 로그인한 사용자가 없다.- 세션 객체: 누군가 로그인했고 객체가 그들의 신원을 담고 있다.
세 번째 값이 처음에 나타나는 새로고침 문제를 해결한다. 세션은 여전히 브라우저 스토리지에 있지만, 첫 렌더링 이후 잠깐 동안 앱이 아직 세션을 읽지 않았다. undefined로 시작하면 잘못된 답 대신 간단한 로딩 상태를 렌더링할 수 있다.
기존 세션을 확인하고 변경 사항을 감시하는 것 모두 React 외부로 나간다. 따라서 첫 렌더링 이후 한 번 실행되는 effect에 속한다.
import { createContext, useContext, useState, useEffect } from 'react'
import { auth } from './authClient'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [session, setSession] = useState(undefined)
useEffect(() => {
async function getInitialSession() {
const { data } = await auth.getSession()
setSession(data.session) // 세션 객체이거나 null
}
getInitialSession()
const {
data: { subscription },
} = auth.onAuthStateChange((_event, session) => {
setSession(session)
})
return () => subscription.unsubscribe()
}, [])
return <AuthContext value={{ session }}>{children}</AuthContext>
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === null) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}effect에서 두 가지 작업이 일어난다. getSession은 일회성 확인을 한다. 이전 방문에서 저장된 세션이 이미 있는가? 그 다음 onAuthStateChange는 리스너를 설정한다. 보안 카메라를 켜는 것처럼 사용자가 로그인하거나 로그아웃할 때마다 실행되고 그 후로 계속 상태를 최신으로 유지한다. 메서드 이름은 provider마다 다르지만, 모든 인증 SDK는 두 가지 작업을 제공한다. 현재 세션을 한 번 읽고 변경 사항을 구독한다.
실행 순서를 주의하세요
느린 getSession이 리스너가 이미 전달한 최신 세션 이후에 해결되어 그것을 오래된 것으로 덮어씌울 수 있다. 많은 SDK는 구독 시 리스너를 초기 세션으로 실행한다. 이 경우 리스너만으로 상태를 설정하면 경합 상태를 제거한다.
JSON Web Token이 무엇인가
한 개의 문자열이 요청마다 함께 움직이며 누가 묻는지 증명한다. 로그인한 앱에서 개발자 도구를 열고 스토리지에서 그 문자열을 복사해 그것을 보유한 사람이 무엇을 볼 수 있는지 묻는다. 답은 모든 것이다. 세션 객체 뒤에는 JSON Web Token이 있다. 점으로 구분된 세 부분이다. 서명 알고리즘을 명시하는 헤더, 사용자에 대한 클레임의 페이로드, 그리고 서명. 각 부분은 base64url로 인코딩되고, 인코딩은 암호화보다는 포맷팅 단계다.
// 토큰은 점으로 연결된 세 개의 base64url 세그먼트
const [header, payload, signature] = token.split('.')
// atob는 표준 base64를 읽으므로 먼저 두 개의 URL 안전 문자를 바꾼다
JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')))
// { sub: '9f2c...', email: '[email protected]', exp: 1789450000 }브라우저 콘솔에서 두 문장, 키는 필요 없다. 따라서 JWT 페이로드는 토큰을 보유한 누구나 읽을 수 있으며, 이것은 한 가지 규칙을 절대적으로 만든다. 비밀은 절대 클레임에 속하지 않는다. 표준 클레임은 사용자의 ID에 대한 sub, 만료 시간에 대한 exp, 그리고 provider가 포함하기로 선택한 다른 것들이다.
서명은 읽는 사람이 위조할 수 없는 부분이다. provider는 헤더와 페이로드를 서명 키로 서명하고, 각 요청에서 서버는 그 서명을 검증한다. 일부 provider는 서명과 검증 모두에 단일 공유 비밀을 사용한다. 그것이 그 비밀이 서버에 남아있는 이유다. 다른 provider는 개인 키로 서명하고 일치하는 공개 키를 게시해서 모든 검증자가 확인할 수 있도록 한다.
어느 경우든 유효한 서명은 토큰이 여기서 발급되었고 그 이후로 변경되지 않았음을 증명한다. 누가 내용을 읽었는지는 말해주지 않는다.
토큰은 브라우저에서, 로컬 스토리지 또는 쿠키에서 살고, 클라이언트 라이브러리는 자동으로 모든 요청에 붙인다. 그것이 어디서 사는지는 보안 결정이다. 로컬 스토리지는 페이지의 모든 JavaScript에서 읽을 수 있으므로 XSS 버그는 토큰 탈취가 된다. httpOnly 쿠키는 스크립트에 보이지 않으므로 탈취를 막지만, 페이지의 스크립트는 실행되는 동안 여전히 사용자로 행동할 수 있고, 쿠키는 자체적인 완화가 필요한 CSRF 우려를 가져온다.
모든 요청에 토큰을 전달하는 것이 JWT 인증을 stateless로 만드는 것이다. 서버는 현재 로그인한 사용자의 목록을 유지하지 않는다. 서명된 토큰 자체가 증명이므로 사용자는 비밀번호를 다시 보내지 않고 매 요청에서 자신을 증명한다.
Supabase는 그 해부학 위에 관례를 계층화하고, 그 관례는 Supabase의 것이지 JWT 스펙의 일부가 아니다. 로그인한 사용자의 토큰은 authenticated를 읽는 role 클레임을 전달하는 반면, 누군가가 로그인하기 전에 만들어진 요청은 anon 역할을 갖는다.
그 인증되지 않은 요청은 클라이언트 번들 내에 배송되는 프로젝트 API 키로 이동한다. 레거시 키는 anon 키라고 불리고 그 자체로 JWT이며, 현재 키는 publishable key라고 불린다. sb_publishable_... 문자열은 전혀 토큰이 아니다. 그것을 배송하는 것은 Row Level Security가 도달할 수 있는 모든 테이블에서 활성화될 때만 안전하고, 다음 섹션이 그 정책을 다룬다. 없으면 개발자 도구를 여는 누구나 그 행을 읽고 쓸 수 있다.
service key를 클라이언트에서 멀리 두세요
publishable key의 대응인 service-role 또는 secret key는 모든 정책을 우회하므로 클라이언트 번들이나 VITE_ 접두사가 붙은 환경 변수에 절대 도달하지 않아야 한다. 번들에 컴파일되기 때문이다.
가입, 로그인, 로그아웃
세 가지 흐름은 한 가지 형태를 공유한다. provider의 SDK 메서드를 호출하고, 인증 리스너가 세션 상태를 업데이트하도록 하고, 네비게이트한다. 각 인증 함수는 일반적으로 세션 상태와 같은 provider 파일에 있고, context 값으로 노출되며, 호출하는 컴포넌트가 행동할 수 있는 순수한 결과를 반환한다.
async function signIn(email, password) {
const { data, error } = await auth.signInWithPassword({
email: email.toLowerCase(),
password,
})
if (error) return { success: false, error: error.message }
return { success: true, data }
}성공하면 provider는 JWT를 발급하고, 클라이언트 라이브러리는 그것을 저장하고 세션 객체를 만들며, onAuthStateChange 리스너가 상태를 업데이트한다. signIn을 호출한 컴포넌트는 결과를 확인하고 네비게이트하기만 하면 된다.
const { signIn } = useAuth()
async function handleSubmit(email, password) {
const { success, error } = await signIn(email, password)
if (success) navigate('/dashboard')
else setError(error)
}다른 두 흐름은 변형이다. 가입은 새 사용자의 자격 증명을 provider에 보내고, provider는 그것을 저장하며, 대부분의 provider와 함께 동시에 사용자를 로그인시키므로 같은 네비게이션 논리가 적용된다. 로그아웃은 SDK의 로그아웃 메서드를 호출하고, 이것은 저장된 토큰을 지우며, 리스너가 변경을 보고 세션을 null로 설정하고 앱이 공개 페이지로 다시 네비게이트한다.
흐름에 대해 React 특정적인 것은 없다. React의 역할은 세션 상태를 보유하고 변경될 때 다시 렌더링하는 것이다.
라우트 보호, 그리고 누가 실제로 접근을 적용하는가
context에서 세션 상태를 사용하면 protected routes 장의 인증 필수 레이아웃 라우트는 삼항 연산자가 된다. 세 가지 세션 값 모두 분기를 갖는다.
function AuthRequired() {
const { session } = useAuth()
if (session === undefined) return <p>Loading...</p>
return session ? <Outlet /> : <Navigate to="/signin" replace />
}private 페이지를 그것 아래에 중첩하고 인증되지 않은 방문자는 그 중 어떤 것도 렌더링되기 전에 리디렉션된다. 그 장은 클라이언트 보호가 사용자 경험 기능이라는 점을 지적했다. 여기 다른 절반이 있다. 실제 접근 제어는 사용자가 도달할 수 없는 서버에서 살아야 한다.
이 과정의 서버 측 적용 예는 Row Level Security다. 데이터베이스 기능으로 접근 정책이 테이블 자체에 붙는다. 그것을 활성화하면 모든 것이 기본적으로 거부되고, 그 다음 정책은 요청의 JWT의 클레임에 기반해 접근을 부여한다. 예를 들어 role = 'authenticated' 또는 행의 owner 열이 토큰의 sub과 일치하는 경우다. 정책은 모든 쿼리에서 데이터베이스 내부에 적용되므로 React 앱을 건너뛰는 요청에도 유지된다.
그것이 허용하는 것은 정확히 정책이 말하는 것이고, publishable key만을 전달하는 요청은 합법적인 요청이므로 항상 참으로 쓰인 정책은 아무것도 보호하지 않는다. 규칙은 확인하는 클레임을 명시해야 한다. 라우트 보호와 정책은 쌍으로 작동하고, 정책은 무게를 담당하는 절반이다.
모든 것의 뒤에는 모든 요청과 함께 이동해서 누가 묻는지 증명하는 서명된 토큰이 있고, 그 토큰을 가진 누구나 안에 있는 것을 읽을 수 있으므로 비밀을 밖에 두세요.
라우트 보호는 로그아웃된 방문자를 정중히 리디렉션하지만, 실제 자물쇠는 서버에 있다.
다음: React에서의 TypeScript, props, 상태, 컴포넌트가 타입을 얻는다.

