프롤로그 - 세 번 로그인하는 지옥
서비스가 하나였을 때는 괜찮았다. 메인 웹사이트에 회원가입/로그인 기능을 만들고, 세션을 관리하면 끝이었다. 그런데 대시보드를 따로 만들었고, 블로그도 별도 도메인으로 분리했다. 그 순간부터 지옥이 시작됐다.
사용자가 메인 사이트에서 로그인하고, 대시보드로 이동하면 또 로그인하라고 한다. 블로그로 가면 또 로그인이다. "왜 계속 로그인해야 하냐"는 불만이 쏟아졌다. 나도 답답했다. 세션을 공유하려고 쿠키 도메인을 조정해봤지만, 서브도메인이 아닌 완전히 다른 도메인이라 작동하지 않았다.
"큰 회사들은 어떻게 하지?" Google을 보자. Gmail에 로그인하면 YouTube, Google Drive, Google Calendar 모두 자동으로 로그인된다. 이게 바로 SSO(Single Sign-On)였다. 한 번 인증으로 여러 서비스에 접근할 수 있는 시스템. 이걸 구현해야 했다.
깨달음: 놀이공원 팔찌처럼 작동한다
SSO를 이해하는 데 결정적이었던 비유가 있다. 놀이공원 입구에서 받는 팔찌다.
놀이공원에 입장할 때 티켓을 끊고 팔찌를 받는다. 그 팔찌만 차고 있으면 롤러코스터, 회전목마, 범퍼카 어디든 갈 수 있다. 각 놀이기구마다 다시 티켓을 끊지 않는다. 직원은 팔찌만 확인하고 "오케이, 타세요"라고 한다.
SSO가 정확히 이렇게 작동한다:
- 입구 (Identity Provider): 사용자가 한 번 로그인하면 "팔찌(토큰)"를 발급받는다
- 놀이기구들 (Service Providers): 각 서비스는 팔찌만 확인하고 접근을 허용한다
- 중앙 관리: 팔찌가 유효한지, 만료됐는지는 입구에서 관리한다
이 비유가 와닿는 순간, SSO의 핵심 구조가 명확해졌다. 인증은 한 곳에서, 확인은 여러 곳에서.
Identity Provider vs Service Provider: 역할 분리
SSO 시스템은 두 가지 주요 역할로 나뉜다:
Identity Provider (IdP) - 인증 담당자
신원을 확인하고 토큰을 발급하는 중앙 시스템이다. 사용자 데이터베이스를 가지고 있고, 비밀번호를 검증하고, 인증서를 발급한다.
- 예시: Google, GitHub, Okta, Azure AD, Auth0
- 역할: "당신이 누구인지 증명해주세요. 맞다면 토큰 드릴게요"
Service Provider (SP) - 서비스 제공자
실제 비즈니스 로직을 제공하는 애플리케이션들이다. 직접 인증하지 않고 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만 믿으면 된다.
SAML vs OAuth 2.0 vs OIDC: 헷갈리는 프로토콜들
SSO를 구현하는 방법은 여러 가지다. 처음에는 이 차이가 정말 헷갈렸다.
SAML (Security Assertion Markup Language)
오래된 엔터프라이즈 표준이다. XML 기반이고, 무겁고, 설정이 복잡하다. 하지만 대기업, 정부 기관에서는 여전히 많이 쓴다.
- 사용처: 기업 SSO (Okta, Azure AD)
- 특징: 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 - 권한 위임
OAuth 2.0은 인증(Authentication)이 아니라 권한 위임(Authorization)을 위한 프로토콜이다. 많은 사람이 헷갈리는 부분이다.
- 목적: "내 Google Drive 파일에 접근하는 걸 허용할게요"
- 사용처: 제3자 앱이 사용자의 리소스에 접근할 때
- 예시: Notion이 내 Google Calendar를 읽고 쓰는 권한
OAuth 2.0 자체는 사용자가 누구인지 알려주지 않는다. 단지 "이 토큰으로 저 API를 호출할 수 있어요"만 말한다.
OIDC (OpenID Connect) - OAuth 위의 인증 레이어
결국 내가 선택한 건 OIDC였다. OAuth 2.0 위에 인증 레이어를 얹은 프로토콜이다.
- 목적: "당신이 누구인지 알려드릴게요" + OAuth 2.0 권한 위임
- 사용처: 현대적인 웹/모바일 SSO
- 예시: "Google로 로그인", "GitHub로 로그인"
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 기반이고, 현대적이고, 모바일 친화적이다.
Authorization Code Flow + PKCE: 실제 동작 과정
OIDC의 가장 안전한 인증 방식은 Authorization Code Flow with PKCE다. 이게 어떻게 작동하는지 단계별로 따라가 봤다.
1단계: 로그인 버튼 클릭
사용자가 "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();
2단계: IdP에서 인증
Google 로그인 페이지로 리다이렉트된다. 사용자가 이메일/비밀번호를 입력하고 로그인한다.
3단계: Authorization Code 받기
인증 성공하면 Google이 https://myapp.com/callback?code=AUTHORIZATION_CODE로 리다이렉트한다. 이 code는 일회용이고, 짧은 시간(보통 10분) 동안만 유효하다.
4단계: 토큰 교환
앱이 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');
});
5단계: 다른 서비스 접근
이제 사용자가 대시보드(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();
}
});
토큰 관리: Access, Refresh, ID Token
SSO를 구현하면서 가장 헷갈렸던 게 세 가지 토큰의 역할 차이였다.
Access Token - 단기 통행증
- 수명: 짧다 (보통 15분~1시간)
- 용도: API 요청할 때
Authorization: Bearer ACCESS_TOKEN헤더에 포함 - 저장: 메모리 or httpOnly 쿠키
- 특징: 만료되면 갱신해야 함
Refresh Token - 재발급 티켓
- 수명: 길다 (며칠~몇 달)
- 용도: access token이 만료되면 새로 발급받을 때 사용
- 저장: httpOnly 쿠키 (절대 localStorage 금지)
- 특징: 한 번 사용하면 새로운 refresh token도 함께 발급 (rotation)
ID Token - 신분증
- 수명: access token과 비슷
- 용도: 사용자 정보 확인용 (이름, 이메일, 프로필)
- 저장: 필요하면 메모리, 보통은 검증 후 버림
- 특징: JWT 형식, 서명 검증 필수
// 토큰 갱신 플로우
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);
}
실제 구현: Auth0 vs Clerk vs Supabase Auth
직접 IdP를 구축하는 건 보안 리스크가 크다. 이미 검증된 서비스를 쓰는 게 현명하다.
Auth0 - 엔터프라이즈급 유연성
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 기반), 설정이 복잡
Clerk - 개발자 경험 최고
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 종속성 높음, 커스터마이징 제한적
Supabase Auth - 오픈소스 + DB 통합
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 구현하면서 배운 보안 원칙들.
1. 토큰 저장 위치
- Access Token: httpOnly 쿠키 (XSS 방어) 또는 메모리 (새로고침 시 재발급)
- Refresh Token: 반드시 httpOnly, Secure, SameSite=Strict 쿠키
- 절대 금지: localStorage/sessionStorage에 민감한 토큰 저장
// 서버에서 쿠키 설정
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // JavaScript 접근 불가
secure: true, // HTTPS만
sameSite: 'strict', // CSRF 방어
maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
path: '/api/auth' // 특정 경로에만
});
2. CSRF (Cross-Site Request Forgery) 방어
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' });
}
// 실제 로직
});
3. Token Rotation
Refresh token은 한 번 사용하면 무효화하고 새로 발급해야 한다. 탈취된 토큰의 재사용을 막는다.
4. Session Fixation 방어
로그인 성공 시 반드시 새로운 세션 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는 선택이 아니라 필수다. 사용자 경험을 위해서도, 개발자 정신 건강을 위해서도.
핵심 교훈:
- 역할 분리: Identity Provider(인증) + Service Provider(검증)로 책임을 나눈다
- OIDC 선택: SAML은 너무 무겁고, OAuth는 인증이 아니다. OIDC가 현대적 표준이다
- PKCE 사용: Authorization Code Flow + PKCE가 가장 안전하다
- 토큰 3종 세트: Access(단기), Refresh(장기), ID(신분) 토큰의 역할을 명확히 구분한다
- 직접 만들지 말기: Auth0, Clerk, Supabase 같은 검증된 서비스를 쓴다
- 보안 원칙: httpOnly 쿠키, CSRF 방어, Token Rotation, Session Regeneration
SSO를 구축한 후 사용자 불만이 사라졌다. 한 번 로그인하면 모든 서비스에 접근할 수 있다. 나도 세 번씩 로그인하는 지옥에서 벗어났다. 놀이공원 팔찌처럼, 한 번 인증으로 모든 문이 열린다.
SSO (Single Sign-On): One Login for Multiple Services
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 Aha Moment: It Works Like an Amusement Park Wristband
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:
- Entrance (Identity Provider): User logs in once and receives a "wristband (token)"
- Rides (Service Providers): Each service checks the wristband and grants access
- Central Management: The entrance manages whether wristbands are valid or expired
The moment this metaphor clicked, SSO's core structure became clear: authenticate once, verify everywhere.
Identity Provider vs Service Provider: Separation of Roles
SSO systems split into two main roles:
Identity Provider (IdP) - Authentication Authority
The central system that verifies identity and issues tokens. It maintains the user database, validates passwords, and issues credentials.
- Examples: Google, GitHub, Okta, Azure AD, Auth0
- Role: "Prove who you are. If correct, here's your token"
Service Provider (SP) - Service Deliverer
The actual applications providing business logic. They don't authenticate directly; they just verify tokens issued by the IdP.
- Examples: My main website, dashboard, blog
- Role: "Got a token? Valid? Come in"
// 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.
SAML vs OAuth 2.0 vs OIDC: Confusing Protocols
Multiple ways exist to implement SSO. Initially, these differences really confused me.
SAML (Security Assertion Markup Language)
An old enterprise standard. XML-based, heavy, complex configuration. But still widely used in large corporations and government agencies.
- Use case: Enterprise SSO (Okta, Azure AD)
- Characteristics: XML-based, complex setup, strong security
- Example: Corporate employee accessing dozens of internal tools with company email
SAML felt too heavy for my project. Overkill for consumer-facing services.
OAuth 2.0 - Authorization Delegation
OAuth 2.0 is for authorization delegation, not authentication. Many people confuse this.
- Purpose: "I allow you to access my Google Drive files"
- Use case: Third-party apps accessing user resources
- Example: Notion reading and writing to my Google Calendar
OAuth 2.0 itself doesn't tell you who the user is. It just says "this token can call that API."
OIDC (OpenID Connect) - Authentication Layer on OAuth
I ultimately chose OIDC. It's an authentication layer built on top of OAuth 2.0.
- Purpose: "Here's who you are" + OAuth 2.0 authorization
- Use case: Modern web/mobile SSO
- Example: "Sign in with Google," "Sign in with GitHub"
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.
Authorization Code Flow + PKCE: How It Actually Works
OIDC's most secure authentication method is Authorization Code Flow with PKCE. Let me walk through how this operates step by step.
Step 1: Click Login Button
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();
Step 2: Authenticate at IdP
Redirects to Google's login page. User enters email/password and authenticates.
Step 3: Receive Authorization Code
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).
Step 4: Exchange for Tokens
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');
});
Step 5: Access Other Services
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.
Token Management: Access, Refresh, ID Tokens
The most confusing part of implementing SSO was understanding the difference between the three tokens.
Access Token - Short-Term Pass
- Lifespan: Short (usually 15 minutes to 1 hour)
- Purpose: Included in API requests as
Authorization: Bearer ACCESS_TOKEN - Storage: Memory or httpOnly cookie
- Note: Must refresh when expired
Refresh Token - Reissue Ticket
- Lifespan: Long (days to months)
- Purpose: Used to obtain new access tokens when they expire
- Storage: httpOnly cookie (never localStorage)
- Note: When used, a new refresh token is also issued (rotation)
ID Token - Identity Card
- Lifespan: Similar to access token
- Purpose: Verify user information (name, email, profile)
- Storage: Memory if needed, usually discarded after verification
- Note: JWT format, signature verification required
// 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);
}
Real Implementation: Auth0 vs Clerk vs Supabase Auth
Building your own IdP is a major security risk. Smarter to use proven services.
Auth0 - Enterprise-Grade Flexibility
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
Clerk - Best Developer Experience
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
Supabase Auth - Open Source + DB Integration
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 Considerations: Mistakes That Hurt
Security principles I learned implementing SSO.
1. Token Storage Location
- Access Token: httpOnly cookie (XSS defense) or memory (reissue on refresh)
- Refresh Token: Must use httpOnly, Secure, SameSite=Strict cookie
- Never: Store sensitive tokens in localStorage/sessionStorage
// 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
});
2. CSRF (Cross-Site Request Forgery) Defense
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
});
3. Token Rotation
Refresh tokens should be invalidated after one use and reissued. Prevents stolen token reuse.
4. Session Fixation Defense
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 });
});
}
});