14 KiB
14 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| security-login-flows | Login Flows — Magic Link / Passkey / OAuth | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
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
// 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)
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,
});
}
// 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) });
Login flow (Passkey)
// 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)
<input type="text" name="username" autocomplete="username webauthn" />
const att = await startAuthentication({ ...options, useBrowserAutofill: true });
→ 사용자 가 username field 클릭 → passkey suggest.
OAuth (Google / Apple / GitHub)
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');
});
Sign in with Apple (iOS / Android required)
// 사용자 의 Apple ID hide email
// "abc123@privaterelay.appleid.com"
// → 정확 email X (privacy)
→ App Store guideline = OAuth 사용 시 Apple Sign-In 도 제공.
Password + 2FA (전통)
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
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 차단
// ❌ "Email not found" — attacker 가 valid email 알아냄
// ✅ 항상 "If account exists, you'll receive an email"
Brute force 방어
// 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
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
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)
// 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
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" 통일
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
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"
// 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
// 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 가 빠른 시작.