--- id: security-login-flows title: Login Flows — Magic Link / Passkey / OAuth category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [security, auth, login, vibe-coding] tech_stack: { language: "TS", applicable_to: ["Backend"] } applied_in: [] aliases: [magic link, passkey, OAuth, social login, password, login flow, account recovery] --- # Login Flows > Password = 옛 (취약). **Magic link / Passkey / OAuth**. 사용자 friction + security trade-off. Account recovery = 가장 어려움. ## 📖 핵심 개념 - Magic link: email 만 — UX 최고, 약한 security. - Passkey: WebAuthn — phishing 차단. - OAuth: 외부 IdP (Google, Apple). - Password + 2FA: 전통 + 강. ## 💻 코드 패턴 ### Magic link ```ts // Send async function sendMagicLink(email: string) { const token = generateSecureToken(); // 32 bytes random await db.magicLinks.create({ token, email, expiresAt: new Date(Date.now() + 10 * 60_000), // 10 min }); await emailService.send({ to: email, subject: 'Sign in to Acme', template: 'magic-link', data: { url: `https://app.acme.com/auth/verify?token=${token}` }, }); } // Verify async function verifyMagicLink(token: string) { const link = await db.magicLinks.findUnique({ where: { token } }); if (!link || link.expiresAt < new Date() || link.used) { throw new Error('Invalid or expired link'); } await db.magicLinks.update({ where: { token }, data: { used: true } }); let user = await db.users.findUnique({ where: { email: link.email } }); if (!user) { user = await db.users.create({ data: { email: link.email } }); } return createSession(user); } ``` → UX 매우 좋음. Email security 가 weakness. ### Passkey (WebAuthn, modern) ```ts import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server'; // Registration (first time) async function startRegistration(userId: string, email: string) { const options = await generateRegistrationOptions({ rpName: 'Acme', rpID: 'acme.com', userID: Buffer.from(userId), userName: email, authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', }, }); await redis.set(`webauthn:reg:${userId}`, options.challenge, 'EX', 300); return options; } async function completeRegistration(userId: string, response: any) { const expectedChallenge = await redis.get(`webauthn:reg:${userId}`); const verification = await verifyRegistrationResponse({ response, expectedChallenge: expectedChallenge!, expectedOrigin: 'https://acme.com', expectedRPID: 'acme.com', }); if (!verification.verified) throw new Error('Failed'); await db.passkeys.create({ userId, credentialId: verification.registrationInfo!.credentialID, publicKey: verification.registrationInfo!.credentialPublicKey, counter: verification.registrationInfo!.counter, }); } ``` ```tsx // Client import { startRegistration } from '@simplewebauthn/browser'; const options = await fetch('/auth/passkey/start').then(r => r.json()); const att = await startRegistration(options); await fetch('/auth/passkey/complete', { method: 'POST', body: JSON.stringify(att) }); ``` → [[Security_2FA_TOTP_WebAuthn]]. ### Login flow (Passkey) ```ts // Start async function startLogin() { const options = await generateAuthenticationOptions({ rpID: 'acme.com', userVerification: 'preferred', }); await redis.set(`webauthn:auth:${options.challenge}`, '1', 'EX', 300); return options; } async function completeLogin(response: any) { const passkey = await db.passkeys.findByCredentialId(response.id); if (!passkey) throw new Error('Unknown passkey'); const verification = await verifyAuthenticationResponse({ response, expectedChallenge: ..., expectedOrigin: 'https://acme.com', expectedRPID: 'acme.com', authenticator: { ...passkey }, }); if (!verification.verified) throw new Error('Failed'); await db.passkeys.update({ where: { id: passkey.id }, data: { counter: verification.authenticationInfo.newCounter } }); return createSession(passkey.userId); } ``` → Phishing 차단 (origin verify). ### Conditional UI (auto-fill passkey) ```html ``` ```ts const att = await startAuthentication({ ...options, useBrowserAutofill: true }); ``` → 사용자 가 username field 클릭 → passkey suggest. ### OAuth (Google / Apple / GitHub) ```ts import { OAuth2Client } from 'google-auth-library'; const client = new OAuth2Client(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, REDIRECT_URI); // Start app.get('/auth/google', (req, res) => { const url = client.generateAuthUrl({ scope: ['openid', 'email', 'profile'], state: generateState(), // CSRF prompt: 'select_account', }); res.redirect(url); }); // Callback app.get('/auth/google/callback', async (req, res) => { const { code, state } = req.query; // Verify state const { tokens } = await client.getToken(code as string); const ticket = await client.verifyIdToken({ idToken: tokens.id_token! }); const payload = ticket.getPayload(); let user = await db.users.findUnique({ where: { email: payload!.email } }); if (!user) { user = await db.users.create({ data: { email: payload!.email!, name: payload!.name, avatar: payload!.picture, }, }); } await createSession(user, res); res.redirect('/dashboard'); }); ``` → [[Security_OAuth_Flows]]. ### Sign in with Apple (iOS / Android required) ```ts // 사용자 의 Apple ID hide email // "abc123@privaterelay.appleid.com" // → 정확 email X (privacy) ``` → App Store guideline = OAuth 사용 시 Apple Sign-In 도 제공. ### Password + 2FA (전통) ```ts async function login(email: string, password: string) { const user = await db.users.findUnique({ where: { email } }); if (!user) throw new Error('Invalid credentials'); const valid = await argon2.verify(user.passwordHash, password); if (!valid) { await recordFailedAttempt(email); throw new Error('Invalid credentials'); } // Lockout const attempts = await getRecentFailedAttempts(email); if (attempts >= 5) throw new Error('Account locked'); if (user.twoFactorEnabled) { const tempToken = signTempToken({ userId: user.id }); return { needs2FA: true, tempToken }; } return createSession(user); } async function verify2FA(tempToken: string, code: string) { const payload = verifyTempToken(tempToken); const user = await db.users.findUnique({ where: { id: payload.userId } }); if (!authenticator.verify({ token: code, secret: user!.totpSecret! })) { throw new Error('Invalid 2FA code'); } return createSession(user!); } ``` ### Account recovery (어려움) ``` 가능 path: 1. Email reset (account = email) 2. Backup codes 3. Recovery passkey (다른 device) 4. SMS (옛, weak) 5. Identity verify (sensitive) 가장 안전: - Multiple recovery (email + passkey + codes) - Account-bound - Audit log ``` ```ts async function startRecovery(email: string) { const user = await db.users.findUnique({ where: { email } }); if (!user) { // Always success (account enumeration 차단) return { sent: true }; } const token = generateSecureToken(); await db.passwordResets.create({ userId: user.id, token, expiresAt: new Date(Date.now() + 60 * 60_000), // 1 hour }); await emailService.send({ to: email, template: 'password-reset', data: { url: `https://app.acme.com/reset?token=${token}` }, }); return { sent: true }; } ``` ### Account enumeration 차단 ```ts // ❌ "Email not found" — attacker 가 valid email 알아냄 // ✅ 항상 "If account exists, you'll receive an email" ``` ### Brute force 방어 ```ts // Rate limit (IP + email) const ipLimit = await rateLimit.check(`login:ip:${ip}`, 10, 60); const emailLimit = await rateLimit.check(`login:email:${email}`, 5, 300); if (!ipLimit || !emailLimit) { return res.status(429).end(); } // Captcha (after N failures) if (failedAttempts > 3) { if (!await verifyCaptcha(req.body.captchaToken)) { return res.status(401).end(); } } ``` ### Session creation ```ts async function createSession(user: User, res: Response) { const sessionId = uuid(); await db.sessions.create({ id: sessionId, userId: user.id, ip: req.ip, userAgent: req.headers['user-agent'], expiresAt: new Date(Date.now() + 7 * 24 * 3600_000), // 7 days }); res.cookie('session', sessionId, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 24 * 3600_000, }); } ``` ### JWT vs session cookie ``` Session cookie: + Server 가 invalidate 가능 + Sensitive + Database lookup - Stateful (DB call) JWT: + Stateless + Multiple service share - Invalidate 어려움 (until expire) - Sensitive (sign key + payload) Refresh token + short-lived JWT: + JWT 의 stateless + invalidate 가능 + Best practice ``` ### Refresh token rotation ```ts async function refresh(refreshToken: string) { const session = await db.sessions.findUnique({ where: { refreshToken } }); if (!session) { // Used / unknown — possible replay await db.sessions.deleteMany({ where: { userId: session?.userId } }); throw new Error('Token reuse — all sessions revoked'); } const newRefresh = generateSecureToken(); await db.sessions.update({ where: { id: session.id }, data: { refreshToken: newRefresh, refreshedAt: new Date() }, }); return { accessToken: signJwt({ userId: session.userId, exp: 15 * 60 }), refreshToken: newRefresh, }; } ``` → Old refresh 사용 시 = revoke all (compromise 의심). ### Mobile flow ``` 1. Login → server return (access, refresh) 2. Access in memory + refresh in Keychain (iOS) / Keystore (Android) 3. Access expired → use refresh 4. Refresh expired → re-login ``` ### Magic code (mobile, 6 digit) ```ts // SMS / Email send 6 digit await sendSMS(phone, `Your code: ${generate6DigitCode()}`); // User input const valid = await verify(phone, code); ``` → Magic link 의 mobile 친화 alternative. ### Social account linking ```ts async function linkGoogle(userId: string, googleSub: string) { // 같은 google account 가 다른 user 에 linked 면? const existing = await db.identities.findUnique({ where: { provider: 'google', sub: googleSub }, }); if (existing && existing.userId !== userId) { throw new Error('Already linked to another account'); } await db.identities.upsert({ where: { userId_provider: { userId, provider: 'google' } }, create: { userId, provider: 'google', sub: googleSub }, update: {}, }); } ``` ### "Sign in or sign up" 통일 ```ts async function googleSignIn(idToken: string) { const payload = await verifyGoogleToken(idToken); let user = await db.users.findFirst({ where: { OR: [ { email: payload.email }, { identities: { some: { provider: 'google', sub: payload.sub } } }, ], }, }); if (!user) { user = await db.users.create({ data: { email: payload.email, name: payload.name, identities: { create: { provider: 'google', sub: payload.sub } }, }, }); } return createSession(user); } ``` → 같은 사용자 가 password + Google = 같은 account. ### Existing user — security questions / email confirm ``` 사용자 가 이미 password — Google login 가 같은 email: - Risk: attacker 가 Google account 가짐 - 첫 link 시 password verify → Account takeover 방어. ``` ### Logout ```ts async function logout(req: Request, res: Response) { await db.sessions.delete({ where: { id: req.cookies.session } }); res.clearCookie('session'); } // Logout from all devices await db.sessions.deleteMany({ where: { userId } }); ``` ### "Trust this device" ```ts // Successful login + sensitive action 가 (2FA bypass) 가능 const trustedDevice = await db.trustedDevices.findFirst({ where: { userId, deviceFingerprint: req.fingerprint, expiresAt: { gt: new Date() } }, }); if (trustedDevice) { // Skip 2FA } ``` → Convenience vs security. ### A/B test login methods ```ts // 50% magic link, 50% password const variant = hashUserId(userId) % 2 === 0 ? 'magic-link' : 'password'; // Track conversion analytics.track('login_completed', { variant }); ``` ### Migration (password → passkey) ``` Phase 1: Add passkey (optional) Phase 2: Prompt to add passkey on login Phase 3: Disable password (keep recovery) Phase 4: Passkey only ``` → 점진. 사용자 educate. ### Best practices ``` 1. HTTPS only 2. HttpOnly + Secure + SameSite cookie 3. CSRF token (cookie + form pair) 4. Rate limit 5. Account enumeration 차단 6. Audit log 7. Phishing-resistant MFA 8. Session fixation 차단 9. Secure cookie domain 10. Logout 명확 ``` ### Common 함정 ``` - Plain password storage (instead of argon2 / bcrypt) - JWT secret weak / leaked - Email reset token long-lived (1 hour 권장) - OAuth state 검증 X (CSRF) - Session ID predictable - Cookie SameSite none + cross-origin - Account enumeration timing attack ``` ## 🤔 의사결정 기준 | 상황 | 추천 | |---|---| | 모던 product | Passkey + Magic link + OAuth | | Quick prototype | Magic link | | Enterprise | OAuth (Okta / Google Workspace) | | Mobile | Apple / Google + biometric | | 옛 user base | Password + 2FA + Passkey opt-in | | B2C | Auth0 / Clerk / Supabase | ## ❌ 안티패턴 - **Password + no MFA**: weak. - **Plain password storage**: 절대 — argon2. - **Magic link 가 long expire (24h)**: 짧게 (10 min). - **2FA SMS only**: SIM swap. - **OAuth state 무**: CSRF. - **Account enumeration**: timing attack 가능. - **Refresh token long + no rotation**: leak 시 영원. ## 🤖 LLM 활용 힌트 - Passkey + magic link + OAuth = modern stack. - Account enumeration 항상 차단. - Rate limit + lockout + captcha. - Auth0 / Clerk 가 빠른 시작. ## 🔗 관련 문서 - [[Security_2FA_TOTP_WebAuthn]] - [[Security_OAuth_Flows]] - [[Security_Auth_Authz_Patterns]]