---
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
<input type="text" name="username" autocomplete="username webauthn" />
```

```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]]
