---
id: security-oauth-flows
title: OAuth 2.1 — Authorization Code + PKCE
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [security, oauth, oidc, vibe-coding]
tech_stack: { language: "TS", applicable_to: ["Backend", "Frontend"] }
applied_in: []
aliases: [OAuth 2.0, OAuth 2.1, OIDC, PKCE, refresh token, Authorization Code]
---

# OAuth 2.1 + OIDC

> Authorization Code + PKCE 가 표준. **Implicit / Password grant deprecated**. Refresh token rotation. 사용자 인증 = OIDC (OAuth 위 layer).

## 📖 핵심 개념
- Authorization Code: 표준 web 흐름. PKCE 필수.
- Client Credentials: 서버 간 (사용자 X).
- Refresh Token: 짧은 access token + 긴 refresh.
- OIDC: id_token (JWT, 사용자 정보).

## 💻 코드 패턴

### Authorization Code + PKCE 흐름
```
1. App 이 code_verifier 생성 (random) → code_challenge = SHA256(verifier)
2. /authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&state=...
3. 사용자 로그인 → IdP 가 redirect_uri 로 code 반환
4. App 이 code + verifier → /token 으로 교환
5. access_token + refresh_token 받음
```

### Client (browser)
```ts
function base64url(buf: ArrayBuffer): string {
  return btoa(String.fromCharCode(...new Uint8Array(buf)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

async function startLogin() {
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
  const challenge = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
  const state = base64url(crypto.getRandomValues(new Uint8Array(16)).buffer);

  sessionStorage.setItem('pkce_verifier', verifier);
  sessionStorage.setItem('oauth_state', state);

  const url = new URL('https://idp.example.com/authorize');
  url.searchParams.set('client_id', CLIENT_ID);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('redirect_uri', REDIRECT_URI);
  url.searchParams.set('code_challenge', challenge);
  url.searchParams.set('code_challenge_method', 'S256');
  url.searchParams.set('state', state);
  url.searchParams.set('scope', 'openid email profile');
  location.href = url.toString();
}

// callback 페이지
async function handleCallback() {
  const params = new URLSearchParams(location.search);
  const state = params.get('state');
  if (state !== sessionStorage.getItem('oauth_state')) throw new Error('CSRF');

  const code = params.get('code');
  const verifier = sessionStorage.getItem('pkce_verifier');
  const r = await fetch('https://idp.example.com/token', {
    method: 'POST', body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code!, redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID, code_verifier: verifier!,
    }),
  });
  const tokens = await r.json();
  // tokens.access_token / id_token / refresh_token
}
```

### Server-side (BFF — backend for frontend)
```ts
// Cookie session 만 client 에 — token 은 server 에 보관
app.get('/auth/login', (req, res) => {
  const { url, state, verifier } = buildAuthUrl();
  res.cookie('pkce', JSON.stringify({ state, verifier }), { httpOnly: true, secure: true, sameSite: 'lax' });
  res.redirect(url);
});

app.get('/auth/callback', async (req, res) => {
  const { state, verifier } = JSON.parse(req.cookies.pkce);
  if (req.query.state !== state) return res.status(400).end();
  const tokens = await exchangeCode(req.query.code, verifier);

  // 사용자 식별
  const idToken = jwt.decode(tokens.id_token);
  await db.userSessions.create({ userId: idToken.sub, ...tokens });

  res.cookie('session', sign({ userId: idToken.sub }), { httpOnly: true, secure: true, sameSite: 'strict' });
  res.redirect('/');
});
```

### Refresh token rotation
```ts
async function refresh(oldRefreshToken: string) {
  const session = await db.sessions.find({ refreshToken: oldRefreshToken });
  if (!session) throw new Error('reuse detected — revoke all sessions');

  const newTokens = await idp.refresh(oldRefreshToken);
  await db.sessions.update(session.id, {
    refreshToken: newTokens.refresh_token,
    accessToken: newTokens.access_token,
    rotatedAt: new Date(),
  });
  // old refresh 사용 시 reuse 검출 → 모든 세션 revoke
  return newTokens;
}
```

### Client Credentials (M2M)
```ts
const r = await fetch('https://idp.example.com/token', {
  method: 'POST',
  headers: { authorization: 'Basic ' + base64(`${id}:${secret}`) },
  body: 'grant_type=client_credentials&scope=read:orders',
});
```

### OIDC discovery
```
GET https://idp.example.com/.well-known/openid-configuration
→ authorization_endpoint, token_endpoint, jwks_uri ...
```

```ts
const cfg = await fetch('https://idp.example.com/.well-known/openid-configuration').then(r => r.json());
const jwks = await fetch(cfg.jwks_uri).then(r => r.json());
// id_token 검증 = JWKS 의 공개키
```

### NextAuth / Auth.js / Clerk / Auth0
대부분 SaaS 추천 — 직접 구현 위험.

## 🤔 의사결정 기준
| 환경 | 추천 |
|---|---|
| Web SPA | Authorization Code + PKCE + BFF |
| Mobile native | Authorization Code + PKCE (system browser) |
| 서버 ↔ 서버 | Client Credentials |
| Single sign-on | OIDC + 기존 IdP (Okta/Auth0/Keycloak) |
| 빠른 구현 | NextAuth / Auth.js / Clerk |
| Custom IdP | Keycloak / Hydra (self-host) |

## ❌ 안티패턴
- **Implicit grant**: deprecated.
- **Password grant**: deprecated.
- **PKCE 없음**: code interception 가능.
- **State 검증 없음**: CSRF 가능.
- **Token localStorage**: XSS 가 token 훔침. HttpOnly cookie + BFF.
- **Refresh token rotate 안 함**: leak 시 영원 사용.
- **id_token 서명 검증 안 함**: 위조 가능.
- **Custom OAuth 직접 구현**: 표준 라이브러리.

## 🤖 LLM 활용 힌트
- Authorization Code + PKCE + state + nonce.
- BFF 패턴 (server 가 token 보관).
- NextAuth / Auth.js 권장 — 직접 X.

## 🔗 관련 문서
- [[Security_2FA_TOTP_WebAuthn]]
- [[Security_OWASP_Top_10_Practical]]
- [[Web_JWT_Patterns]]
