
SSO(Single Sign-On): 한 번 로그인으로 여러 서비스
서비스가 3개로 늘어나면서 각각 로그인을 구현하는 게 지옥이었다. SSO로 한 번의 인증으로 모든 서비스에 접근하게 만든 이야기.

서비스가 3개로 늘어나면서 각각 로그인을 구현하는 게 지옥이었다. SSO로 한 번의 인증으로 모든 서비스에 접근하게 만든 이야기.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

Debug에선 잘 되는데 Release에서만 죽나요? 범인은 '난독화'입니다. R8의 원리, Mapping 파일 분석, 그리고 Reflection을 사용하는 라이브러리를 지켜내는 방법(@Keep)을 정리해봤습니다.

비트코인은 블록체인의 일부입니다. 이중 지불 문제(Double Spending), 작업 증명(PoW)과 지분 증명(PoS)의 차이, 스마트 컨트랙트, 그리고 Web 3.0이 가져올 미래까지. 개발자 관점에서 본 블록체인의 모든 것.

내 서버가 해킹당하지 않는 이유. 포트와 IP를 검사하는 '패킷 필터링'부터 AWS Security Group까지, 방화벽의 진화 과정.

서비스가 하나였을 때는 괜찮았다. 메인 웹사이트에 회원가입/로그인 기능을 만들고, 세션을 관리하면 끝이었다. 그런데 대시보드를 따로 만들었고, 블로그도 별도 도메인으로 분리했다. 그 순간부터 지옥이 시작됐다.
사용자가 메인 사이트에서 로그인하고, 대시보드로 이동하면 또 로그인하라고 한다. 블로그로 가면 또 로그인이다. "왜 계속 로그인해야 하냐"는 불만이 쏟아졌다. 나도 답답했다. 세션을 공유하려고 쿠키 도메인을 조정해봤지만, 서브도메인이 아닌 완전히 다른 도메인이라 작동하지 않았다.
"큰 회사들은 어떻게 하지?" Google을 보자. Gmail에 로그인하면 YouTube, Google Drive, Google Calendar 모두 자동으로 로그인된다. 이게 바로 SSO(Single Sign-On)였다. 한 번 인증으로 여러 서비스에 접근할 수 있는 시스템. 이걸 구현해야 했다.
SSO를 이해하는 데 결정적이었던 비유가 있다. 놀이공원 입구에서 받는 팔찌다.
놀이공원에 입장할 때 티켓을 끊고 팔찌를 받는다. 그 팔찌만 차고 있으면 롤러코스터, 회전목마, 범퍼카 어디든 갈 수 있다. 각 놀이기구마다 다시 티켓을 끊지 않는다. 직원은 팔찌만 확인하고 "오케이, 타세요"라고 한다.
SSO가 정확히 이렇게 작동한다:
이 비유가 와닿는 순간, SSO의 핵심 구조가 명확해졌다. 인증은 한 곳에서, 확인은 여러 곳에서.
SSO 시스템은 두 가지 주요 역할로 나뉜다:
신원을 확인하고 토큰을 발급하는 중앙 시스템이다. 사용자 데이터베이스를 가지고 있고, 비밀번호를 검증하고, 인증서를 발급한다.
실제 비즈니스 로직을 제공하는 애플리케이션들이다. 직접 인증하지 않고 IdP가 발급한 토큰만 검증한다.
// Service Provider가 토큰을 검증하는 방식 (개념적 예시)
async function verifyAccessToken(token: string) {
// IdP의 공개키로 토큰 서명 검증
const payload = await verifyJWT(token, IDP_PUBLIC_KEY);
if (payload.exp < Date.now()) {
throw new Error('Token expired');
}
return {
userId: payload.sub,
email: payload.email,
roles: payload.roles
};
}
// Express 미들웨어로 보호된 라우트
app.get('/dashboard', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.redirect('https://auth.example.com/login');
}
try {
const user = await verifyAccessToken(token);
res.render('dashboard', { user });
} catch (error) {
return res.redirect('https://auth.example.com/login');
}
});
이 역할 분리 덕분에 각 서비스는 복잡한 인증 로직을 구현할 필요가 없다. IdP만 믿으면 된다.
SSO를 구현하는 방법은 여러 가지다. 처음에는 이 차이가 정말 헷갈렸다.
오래된 엔터프라이즈 표준이다. XML 기반이고, 무겁고, 설정이 복잡하다. 하지만 대기업, 정부 기관에서는 여전히 많이 쓴다.
<!-- SAML Response 예시 (간략화) -->
<samlp:Response>
<saml:Assertion>
<saml:Subject>
<saml:NameID>user@company.com</saml:NameID>
</saml:Subject>
<saml:AttributeStatement>
<saml:Attribute Name="email">
<saml:AttributeValue>user@company.com</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="role">
<saml:AttributeValue>admin</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
SAML은 너무 무거워서 내 프로젝트에는 맞지 않았다. 소비자 대상 서비스에는 과한 기술이다.
OAuth 2.0은 인증(Authentication)이 아니라 권한 위임(Authorization)을 위한 프로토콜이다. 많은 사람이 헷갈리는 부분이다.
OAuth 2.0 자체는 사용자가 누구인지 알려주지 않는다. 단지 "이 토큰으로 저 API를 호출할 수 있어요"만 말한다.
결국 내가 선택한 건 OIDC였다. OAuth 2.0 위에 인증 레이어를 얹은 프로토콜이다.
OIDC는 OAuth 2.0의 인가 플로우에 ID Token을 추가했다. 이 토큰에는 사용자 정보(이름, 이메일, 프로필 사진)가 들어있다.
// OIDC 인증 후 받는 토큰들
interface OIDCTokens {
access_token: string; // API 호출용 (OAuth 2.0)
id_token: string; // 사용자 정보 (OIDC 추가)
refresh_token: string; // 토큰 갱신용
token_type: 'Bearer';
expires_in: number; // 3600 (1시간)
}
// ID Token 디코딩하면 사용자 정보가 나온다
const idTokenPayload = {
iss: 'https://auth.example.com', // 발급자
sub: 'user123', // 사용자 고유 ID
aud: 'my-app-client-id', // 대상 앱
exp: 1707836400, // 만료 시간
iat: 1707832800, // 발급 시간
email: 'user@example.com',
name: 'John Doe',
picture: 'https://...'
};
OIDC가 딱 맞았다. JSON 기반이고, 현대적이고, 모바일 친화적이다.
OIDC의 가장 안전한 인증 방식은 Authorization Code Flow with PKCE다. 이게 어떻게 작동하는지 단계별로 따라가 봤다.
사용자가 "Google로 로그인" 버튼을 누른다. 앱은 code_verifier(무작위 문자열)를 생성하고, 이를 해싱한 code_challenge를 만든다.
// PKCE 코드 생성
function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
function generateCodeChallenge(verifier: string): string {
const hash = crypto.subtle.digest('SHA-256',
new TextEncoder().encode(verifier)
);
return base64UrlEncode(new Uint8Array(hash));
}
// 로그인 시작
const codeVerifier = generateCodeVerifier();
localStorage.setItem('code_verifier', codeVerifier);
const codeChallenge = generateCodeChallenge(codeVerifier);
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authUrl.searchParams.set('client_id', 'YOUR_CLIENT_ID');
authUrl.searchParams.set('redirect_uri', 'https://myapp.com/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid email profile');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
Google 로그인 페이지로 리다이렉트된다. 사용자가 이메일/비밀번호를 입력하고 로그인한다.
인증 성공하면 Google이 https://myapp.com/callback?code=AUTHORIZATION_CODE로 리다이렉트한다. 이 code는 일회용이고, 짧은 시간(보통 10분) 동안만 유효하다.
앱이 code와 code_verifier를 IdP에 보내서 실제 토큰들을 받는다.
// 콜백 처리 (서버 사이드)
app.get('/callback', async (req, res) => {
const code = req.query.code as string;
const codeVerifier = req.session.codeVerifier; // 세션에 저장했던 값
// 토큰 교환
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
code: code,
code_verifier: codeVerifier,
grant_type: 'authorization_code',
redirect_uri: 'https://myapp.com/callback'
})
});
const tokens = await tokenResponse.json();
/*
{
access_token: "ya29.a0AfH6SMBx...",
id_token: "eyJhbGciOiJSUzI1NiIs...",
refresh_token: "1//0gL3qPZm...",
expires_in: 3600,
token_type: "Bearer"
}
*/
// ID Token 검증 및 사용자 정보 추출
const userInfo = verifyAndDecodeIdToken(tokens.id_token);
// 세션 생성
req.session.userId = userInfo.sub;
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
res.redirect('/dashboard');
});
이제 사용자가 대시보드(dashboard.example.com)에서 블로그(blog.example.com)로 이동한다. 블로그는 access_token만 확인하고 접근을 허용한다.
// 블로그 서비스의 인증 미들웨어
app.use(async (req, res, next) => {
const token = req.cookies.access_token;
if (!token) {
return res.redirect('https://auth.example.com/login');
}
try {
// JWT 검증 (IdP의 공개키 사용)
const user = await verifyJWT(token, process.env.IDP_PUBLIC_KEY);
req.user = user;
next();
} catch (error) {
// 토큰 만료 시 refresh token으로 갱신 시도
const newToken = await refreshAccessToken(req.cookies.refresh_token);
res.cookie('access_token', newToken);
req.user = await verifyJWT(newToken, process.env.IDP_PUBLIC_KEY);
next();
}
});
SSO를 구현하면서 가장 헷갈렸던 게 세 가지 토큰의 역할 차이였다.
Authorization: Bearer ACCESS_TOKEN 헤더에 포함// 토큰 갱신 플로우
async function refreshAccessToken(refreshToken: string) {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.CLIENT_ID
})
});
const tokens = await response.json();
return {
accessToken: tokens.access_token,
newRefreshToken: tokens.refresh_token, // Rotation
expiresIn: tokens.expires_in
};
}
// 클라이언트 사이드 자동 갱신
let tokenRefreshTimeout: NodeJS.Timeout;
function scheduleTokenRefresh(expiresIn: number) {
// 만료 5분 전에 갱신
const refreshTime = (expiresIn - 300) * 1000;
tokenRefreshTimeout = setTimeout(async () => {
const response = await fetch('/api/refresh', {
method: 'POST',
credentials: 'include' // httpOnly 쿠키 전송
});
const { expiresIn } = await response.json();
scheduleTokenRefresh(expiresIn);
}, refreshTime);
}
직접 IdP를 구축하는 건 보안 리스크가 크다. 이미 검증된 서비스를 쓰는 게 현명하다.
import { Auth0Provider, useAuth0 } from '@auth0/auth0-react';
// App.tsx
<Auth0Provider
domain="dev-xxxxx.auth0.com"
clientId="YOUR_CLIENT_ID"
authorizationParams={{
redirect_uri: window.location.origin,
audience: 'https://api.example.com',
scope: 'openid profile email'
}}
>
<App />
</Auth0Provider>
// LoginButton.tsx
function LoginButton() {
const { loginWithRedirect } = useAuth0();
return <button onClick={loginWithRedirect}>Log In</button>;
}
// ProtectedRoute.tsx
function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading, getAccessTokenSilently } = useAuth0();
useEffect(() => {
const getToken = async () => {
const token = await getAccessTokenSilently();
// API 호출에 사용
};
if (isAuthenticated) getToken();
}, [isAuthenticated]);
if (isLoading) return <div>Loading...</div>;
if (!isAuthenticated) return <Redirect to="/login" />;
return children;
}
장점: 소셜 로그인, SAML, 커스텀 DB 모두 지원, Rule/Action으로 커스터마이징 단점: 가격이 비싸다 (MAU 기반), 설정이 복잡
import { ClerkProvider, SignIn, useUser, SignedIn, SignedOut } from '@clerk/clerk-react';
// App.tsx
<ClerkProvider publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}>
<SignedIn>
<Dashboard />
</SignedIn>
<SignedOut>
<SignIn routing="path" path="/sign-in" />
</SignedOut>
</ClerkProvider>
// Dashboard.tsx
function Dashboard() {
const { user } = useUser();
return (
<div>
<h1>Welcome, {user.firstName}!</h1>
<img src={user.profileImageUrl} alt="Profile" />
<p>{user.primaryEmailAddress.emailAddress}</p>
</div>
);
}
// API 라우트 보호 (Next.js)
import { getAuth } from '@clerk/nextjs/server';
export default async function handler(req, res) {
const { userId } = getAuth(req);
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
// 비즈니스 로직
res.json({ data: 'Protected data' });
}
장점: UI 컴포넌트 제공, 설정 거의 없음, 멀티 테넌시 지원 단점: Clerk 종속성 높음, 커스터마이징 제한적
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
'https://xxxxx.supabase.co',
'YOUR_ANON_KEY'
);
// 소셜 로그인
async function signInWithGitHub() {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: 'https://myapp.com/dashboard'
}
});
}
// 세션 확인
supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN') {
console.log('User signed in:', session.user);
console.log('Access token:', session.access_token);
}
if (event === 'TOKEN_REFRESHED') {
console.log('Token refreshed');
}
});
// RLS (Row Level Security)로 데이터 보호
// SQL:
// CREATE POLICY "Users can only see their own data"
// ON public.profiles
// FOR SELECT
// USING (auth.uid() = user_id);
async function getProfile() {
// 자동으로 현재 사용자 데이터만 조회됨
const { data, error } = await supabase
.from('profiles')
.select('*');
}
장점: DB + Auth 통합, 저렴, 오픈소스 단점: 엔터프라이즈 SSO 약함, 커스터마이징 직접 해야 함
내 선택은 Supabase Auth였다. 이미 Supabase를 DB로 쓰고 있었고, 소셜 로그인만 필요했기 때문이다.
SSO 구현하면서 배운 보안 원칙들.
// 서버에서 쿠키 설정
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // JavaScript 접근 불가
secure: true, // HTTPS만
sameSite: 'strict', // CSRF 방어
maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
path: '/api/auth' // 특정 경로에만
});
PKCE를 쓰면 Authorization Code Flow의 CSRF는 자동으로 방어된다. 하지만 API 요청은 별도로 보호해야 한다.
// CSRF 토큰 생성 (서버)
const csrfToken = crypto.randomBytes(32).toString('hex');
req.session.csrfToken = csrfToken;
res.cookie('csrf_token', csrfToken, { httpOnly: false }); // 클라이언트가 읽을 수 있어야 함
// API 요청 시 검증
app.post('/api/sensitive-action', (req, res) => {
const clientCsrfToken = req.headers['x-csrf-token'];
const serverCsrfToken = req.session.csrfToken;
if (clientCsrfToken !== serverCsrfToken) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
// 실제 로직
});
Refresh token은 한 번 사용하면 무효화하고 새로 발급해야 한다. 탈취된 토큰의 재사용을 막는다.
로그인 성공 시 반드시 새로운 세션 ID를 발급한다.
app.post('/login', async (req, res) => {
const user = await authenticateUser(req.body);
if (user) {
// 기존 세션 파괴
req.session.regenerate((err) => {
if (err) throw err;
// 새 세션에 사용자 정보 저장
req.session.userId = user.id;
res.json({ success: true });
});
}
});
서비스가 여러 개로 나뉘는 순간, SSO는 선택이 아니라 필수다. 사용자 경험을 위해서도, 개발자 정신 건강을 위해서도.
핵심 교훈:
SSO를 구축한 후 사용자 불만이 사라졌다. 한 번 로그인하면 모든 서비스에 접근할 수 있다. 나도 세 번씩 로그인하는 지옥에서 벗어났다. 놀이공원 팔찌처럼, 한 번 인증으로 모든 문이 열린다.
When I had one service, authentication was straightforward. Build a login system, manage sessions, done. But then I split things up: a main website, a separate dashboard, and a blog on its own domain. That's when hell began.
Users logged into the main site, moved to the dashboard, and were asked to log in again. Then again on the blog. "Why do I have to keep logging in?" they complained. I was frustrated too. I tried sharing sessions by tweaking cookie domains, but it didn't work across completely different domains.
"How do big companies handle this?" Look at Google. Log into Gmail, and you're automatically signed into YouTube, Google Drive, and Google Calendar. That's SSO (Single Sign-On). One authentication grants access to multiple services. I needed to build this.
The metaphor that made SSO click for me: amusement park wristbands.
When you enter an amusement park, you buy a ticket and get a wristband. With that wristband, you can ride the roller coaster, carousel, and bumper cars. You don't buy a new ticket at each ride. Staff just check your wristband and say, "Okay, go ahead."
SSO works exactly like this:
The moment this metaphor clicked, SSO's core structure became clear: authenticate once, verify everywhere.
SSO systems split into two main roles:
The central system that verifies identity and issues tokens. It maintains the user database, validates passwords, and issues credentials.
The actual applications providing business logic. They don't authenticate directly; they just verify tokens issued by the IdP.
// How Service Providers verify tokens (conceptual)
async function verifyAccessToken(token: string) {
// Verify token signature with IdP's public key
const payload = await verifyJWT(token, IDP_PUBLIC_KEY);
if (payload.exp < Date.now()) {
throw new Error('Token expired');
}
return {
userId: payload.sub,
email: payload.email,
roles: payload.roles
};
}
// Express middleware protecting a route
app.get('/dashboard', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.redirect('https://auth.example.com/login');
}
try {
const user = await verifyAccessToken(token);
res.render('dashboard', { user });
} catch (error) {
return res.redirect('https://auth.example.com/login');
}
});
This role separation means each service doesn't need complex authentication logic. Just trust the IdP.
Multiple ways exist to implement SSO. Initially, these differences really confused me.
An old enterprise standard. XML-based, heavy, complex configuration. But still widely used in large corporations and government agencies.
SAML felt too heavy for my project. Overkill for consumer-facing services.
OAuth 2.0 is for authorization delegation, not authentication. Many people confuse this.
OAuth 2.0 itself doesn't tell you who the user is. It just says "this token can call that API."
I ultimately chose OIDC. It's an authentication layer built on top of OAuth 2.0.
OIDC adds an ID Token to OAuth 2.0's authorization flow. This token contains user information (name, email, profile picture).
// Tokens received after OIDC authentication
interface OIDCTokens {
access_token: string; // For API calls (OAuth 2.0)
id_token: string; // User info (OIDC addition)
refresh_token: string; // For token renewal
token_type: 'Bearer';
expires_in: number; // 3600 (1 hour)
}
// Decoding ID Token reveals user information
const idTokenPayload = {
iss: 'https://auth.example.com', // Issuer
sub: 'user123', // User unique ID
aud: 'my-app-client-id', // Target app
exp: 1707836400, // Expiration time
iat: 1707832800, // Issued at
email: 'user@example.com',
name: 'John Doe',
picture: 'https://...'
};
OIDC was perfect. JSON-based, modern, mobile-friendly.
OIDC's most secure authentication method is Authorization Code Flow with PKCE. Let me walk through how this operates step by step.
User clicks "Sign in with Google." The app generates a random code_verifier and creates a code_challenge by hashing it.
// PKCE code generation
function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
function generateCodeChallenge(verifier: string): string {
const hash = crypto.subtle.digest('SHA-256',
new TextEncoder().encode(verifier)
);
return base64UrlEncode(new Uint8Array(hash));
}
// Start login
const codeVerifier = generateCodeVerifier();
localStorage.setItem('code_verifier', codeVerifier);
const codeChallenge = generateCodeChallenge(codeVerifier);
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authUrl.searchParams.set('client_id', 'YOUR_CLIENT_ID');
authUrl.searchParams.set('redirect_uri', 'https://myapp.com/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid email profile');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
Redirects to Google's login page. User enters email/password and authenticates.
On successful authentication, Google redirects to https://myapp.com/callback?code=AUTHORIZATION_CODE. This code is single-use and valid for a short time (usually 10 minutes).
The app sends the code and code_verifier to the IdP to receive actual tokens.
// Callback handling (server-side)
app.get('/callback', async (req, res) => {
const code = req.query.code as string;
const codeVerifier = req.session.codeVerifier; // Stored in session
// Exchange tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
code: code,
code_verifier: codeVerifier,
grant_type: 'authorization_code',
redirect_uri: 'https://myapp.com/callback'
})
});
const tokens = await tokenResponse.json();
// Verify and extract user info from ID Token
const userInfo = verifyAndDecodeIdToken(tokens.id_token);
// Create session
req.session.userId = userInfo.sub;
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
res.redirect('/dashboard');
});
Now when the user moves from dashboard (dashboard.example.com) to blog (blog.example.com), the blog just verifies the access_token and grants access.
The most confusing part of implementing SSO was understanding the difference between the three tokens.
Authorization: Bearer ACCESS_TOKEN// Token refresh flow
async function refreshAccessToken(refreshToken: string) {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.CLIENT_ID
})
});
const tokens = await response.json();
return {
accessToken: tokens.access_token,
newRefreshToken: tokens.refresh_token, // Rotation
expiresIn: tokens.expires_in
};
}
// Client-side automatic refresh
let tokenRefreshTimeout: NodeJS.Timeout;
function scheduleTokenRefresh(expiresIn: number) {
// Refresh 5 minutes before expiration
const refreshTime = (expiresIn - 300) * 1000;
tokenRefreshTimeout = setTimeout(async () => {
const response = await fetch('/api/refresh', {
method: 'POST',
credentials: 'include' // Send httpOnly cookies
});
const { expiresIn } = await response.json();
scheduleTokenRefresh(expiresIn);
}, refreshTime);
}
Building your own IdP is a major security risk. Smarter to use proven services.
import { Auth0Provider, useAuth0 } from '@auth0/auth0-react';
// App.tsx
<Auth0Provider
domain="dev-xxxxx.auth0.com"
clientId="YOUR_CLIENT_ID"
authorizationParams={{
redirect_uri: window.location.origin,
audience: 'https://api.example.com',
scope: 'openid profile email'
}}
>
<App />
</Auth0Provider>
// LoginButton.tsx
function LoginButton() {
const { loginWithRedirect } = useAuth0();
return <button onClick={loginWithRedirect}>Log In</button>;
}
Pros: Supports social login, SAML, custom DB, customizable with Rules/Actions Cons: Expensive (MAU-based), complex configuration
import { ClerkProvider, SignIn, useUser, SignedIn, SignedOut } from '@clerk/clerk-react';
// App.tsx
<ClerkProvider publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}>
<SignedIn>
<Dashboard />
</SignedIn>
<SignedOut>
<SignIn routing="path" path="/sign-in" />
</SignedOut>
</ClerkProvider>
// Dashboard.tsx
function Dashboard() {
const { user } = useUser();
return (
<div>
<h1>Welcome, {user.firstName}!</h1>
<img src={user.profileImageUrl} alt="Profile" />
<p>{user.primaryEmailAddress.emailAddress}</p>
</div>
);
}
Pros: Provides UI components, minimal setup, multi-tenancy support Cons: High Clerk dependency, limited customization
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
'https://xxxxx.supabase.co',
'YOUR_ANON_KEY'
);
// Social login
async function signInWithGitHub() {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: 'https://myapp.com/dashboard'
}
});
}
// Session monitoring
supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN') {
console.log('User signed in:', session.user);
console.log('Access token:', session.access_token);
}
if (event === 'TOKEN_REFRESHED') {
console.log('Token refreshed');
}
});
Pros: DB + Auth integration, affordable, open source Cons: Weaker enterprise SSO, DIY customization
My choice was Supabase Auth. I was already using Supabase as my database, and I only needed social login.
Security principles I learned implementing SSO.
// Server-side cookie configuration
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // No JavaScript access
secure: true, // HTTPS only
sameSite: 'strict', // CSRF defense
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth' // Specific path only
});
Using PKCE automatically defends against CSRF in Authorization Code Flow. But API requests need separate protection.
// Generate CSRF token (server)
const csrfToken = crypto.randomBytes(32).toString('hex');
req.session.csrfToken = csrfToken;
res.cookie('csrf_token', csrfToken, { httpOnly: false }); // Client must read
// Verify on API request
app.post('/api/sensitive-action', (req, res) => {
const clientCsrfToken = req.headers['x-csrf-token'];
const serverCsrfToken = req.session.csrfToken;
if (clientCsrfToken !== serverCsrfToken) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
// Actual logic
});
Refresh tokens should be invalidated after one use and reissued. Prevents stolen token reuse.
Always issue a new session ID upon successful login.
app.post('/login', async (req, res) => {
const user = await authenticateUser(req.body);
if (user) {
// Destroy old session
req.session.regenerate((err) => {
if (err) throw err;
// Save user info in new session
req.session.userId = user.id;
res.json({ success: true });
});
}
});