Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
560 lines
15 KiB
Markdown
560 lines
15 KiB
Markdown
# Authentication Patterns für moderne Apps
|
||
|
||
**Meta-Description:** Moderne Authentication Patterns. JWT, Session Auth, OAuth 2.0, Passkeys und Auth.js Integration für Next.js.
|
||
|
||
**Keywords:** Authentication, JWT, OAuth, Passkeys, Session Auth, Auth.js, NextAuth, Security, OIDC
|
||
|
||
---
|
||
|
||
## Einführung
|
||
|
||
Authentication ist die erste Verteidigungslinie jeder Anwendung. 2026 stehen mehrere Patterns zur Verfügung: **JWT, Sessions, OAuth, Passkeys** – jedes mit eigenen Trade-offs für Security und UX.
|
||
|
||
---
|
||
|
||
## Auth Patterns Overview
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ AUTHENTICATION PATTERNS │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Session-Based (Stateful): │
|
||
│ ├── Server speichert Session │
|
||
│ ├── Cookie mit Session ID │
|
||
│ ├── Einfach zu invalidieren │
|
||
│ └── Best for: Traditional Web Apps │
|
||
│ │
|
||
│ JWT (Stateless): │
|
||
│ ├── Self-contained Token │
|
||
│ ├── Keine Server-Speicherung │
|
||
│ ├── Schwer zu invalidieren │
|
||
│ └── Best for: APIs, Microservices │
|
||
│ │
|
||
│ OAuth 2.0 / OIDC: │
|
||
│ ├── Delegierte Authentifizierung │
|
||
│ ├── Google, GitHub, etc. │
|
||
│ ├── Refresh Token Flow │
|
||
│ └── Best for: Social Login │
|
||
│ │
|
||
│ Passkeys (WebAuthn): │
|
||
│ ├── Passwordless │
|
||
│ ├── Biometric/Device-based │
|
||
│ ├── Phishing-resistant │
|
||
│ └── Best for: High Security, Modern UX │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Auth.js (NextAuth) v5 Setup
|
||
|
||
```bash
|
||
npm install next-auth@beta
|
||
```
|
||
|
||
```typescript
|
||
// auth.ts
|
||
import NextAuth from 'next-auth';
|
||
import GitHub from 'next-auth/providers/github';
|
||
import Google from 'next-auth/providers/google';
|
||
import Credentials from 'next-auth/providers/credentials';
|
||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||
import { prisma } from '@/lib/prisma';
|
||
import { z } from 'zod';
|
||
import bcrypt from 'bcryptjs';
|
||
|
||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||
adapter: PrismaAdapter(prisma),
|
||
providers: [
|
||
GitHub({
|
||
clientId: process.env.GITHUB_ID!,
|
||
clientSecret: process.env.GITHUB_SECRET!
|
||
}),
|
||
Google({
|
||
clientId: process.env.GOOGLE_ID!,
|
||
clientSecret: process.env.GOOGLE_SECRET!
|
||
}),
|
||
Credentials({
|
||
credentials: {
|
||
email: { label: 'Email', type: 'email' },
|
||
password: { label: 'Password', type: 'password' }
|
||
},
|
||
async authorize(credentials) {
|
||
const parsed = z.object({
|
||
email: z.string().email(),
|
||
password: z.string().min(8)
|
||
}).safeParse(credentials);
|
||
|
||
if (!parsed.success) return null;
|
||
|
||
const user = await prisma.user.findUnique({
|
||
where: { email: parsed.data.email }
|
||
});
|
||
|
||
if (!user?.password) return null;
|
||
|
||
const valid = await bcrypt.compare(
|
||
parsed.data.password,
|
||
user.password
|
||
);
|
||
|
||
if (!valid) return null;
|
||
|
||
return {
|
||
id: user.id,
|
||
email: user.email,
|
||
name: user.name
|
||
};
|
||
}
|
||
})
|
||
],
|
||
session: {
|
||
strategy: 'jwt' // oder 'database'
|
||
},
|
||
callbacks: {
|
||
async jwt({ token, user }) {
|
||
if (user) {
|
||
token.id = user.id;
|
||
token.role = user.role;
|
||
}
|
||
return token;
|
||
},
|
||
async session({ session, token }) {
|
||
session.user.id = token.id as string;
|
||
session.user.role = token.role as string;
|
||
return session;
|
||
}
|
||
},
|
||
pages: {
|
||
signIn: '/login',
|
||
error: '/login'
|
||
}
|
||
});
|
||
```
|
||
|
||
```typescript
|
||
// app/api/auth/[...nextauth]/route.ts
|
||
import { handlers } from '@/auth';
|
||
export const { GET, POST } = handlers;
|
||
|
||
// middleware.ts
|
||
import { auth } from '@/auth';
|
||
|
||
export default auth((req) => {
|
||
if (!req.auth && req.nextUrl.pathname.startsWith('/dashboard')) {
|
||
return Response.redirect(new URL('/login', req.nextUrl));
|
||
}
|
||
});
|
||
|
||
export const config = {
|
||
matcher: ['/dashboard/:path*', '/api/protected/:path*']
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## JWT Implementation (Custom)
|
||
|
||
```typescript
|
||
// lib/jwt.ts
|
||
import { SignJWT, jwtVerify } from 'jose';
|
||
|
||
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
|
||
|
||
interface TokenPayload {
|
||
userId: string;
|
||
email: string;
|
||
role: string;
|
||
}
|
||
|
||
// Access Token (kurze Lebenszeit)
|
||
export async function createAccessToken(payload: TokenPayload) {
|
||
return new SignJWT(payload)
|
||
.setProtectedHeader({ alg: 'HS256' })
|
||
.setIssuedAt()
|
||
.setExpirationTime('15m') // 15 Minuten
|
||
.sign(secret);
|
||
}
|
||
|
||
// Refresh Token (lange Lebenszeit)
|
||
export async function createRefreshToken(userId: string) {
|
||
return new SignJWT({ userId })
|
||
.setProtectedHeader({ alg: 'HS256' })
|
||
.setIssuedAt()
|
||
.setExpirationTime('7d') // 7 Tage
|
||
.sign(secret);
|
||
}
|
||
|
||
export async function verifyToken(token: string) {
|
||
try {
|
||
const { payload } = await jwtVerify(token, secret);
|
||
return payload as TokenPayload & { exp: number };
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Token Rotation Pattern
|
||
export async function refreshTokens(refreshToken: string) {
|
||
const payload = await verifyToken(refreshToken);
|
||
if (!payload) throw new Error('Invalid refresh token');
|
||
|
||
// Optional: Refresh Token im DB invalidieren (Rotation)
|
||
await db.refreshToken.delete({
|
||
where: { token: refreshToken }
|
||
});
|
||
|
||
const user = await db.user.findUnique({
|
||
where: { id: payload.userId }
|
||
});
|
||
|
||
if (!user) throw new Error('User not found');
|
||
|
||
const newAccessToken = await createAccessToken({
|
||
userId: user.id,
|
||
email: user.email,
|
||
role: user.role
|
||
});
|
||
|
||
const newRefreshToken = await createRefreshToken(user.id);
|
||
|
||
// Neuen Refresh Token speichern
|
||
await db.refreshToken.create({
|
||
data: {
|
||
token: newRefreshToken,
|
||
userId: user.id,
|
||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||
}
|
||
});
|
||
|
||
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Passkeys (WebAuthn)
|
||
|
||
```typescript
|
||
// lib/passkeys.ts
|
||
import {
|
||
generateRegistrationOptions,
|
||
verifyRegistrationResponse,
|
||
generateAuthenticationOptions,
|
||
verifyAuthenticationResponse
|
||
} from '@simplewebauthn/server';
|
||
|
||
const rpName = 'My App';
|
||
const rpID = 'example.com';
|
||
const origin = 'https://example.com';
|
||
|
||
// Registration
|
||
export async function startPasskeyRegistration(userId: string, email: string) {
|
||
const existingCredentials = await db.credential.findMany({
|
||
where: { userId }
|
||
});
|
||
|
||
const options = await generateRegistrationOptions({
|
||
rpName,
|
||
rpID,
|
||
userID: userId,
|
||
userName: email,
|
||
attestationType: 'none',
|
||
excludeCredentials: existingCredentials.map(c => ({
|
||
id: c.credentialId,
|
||
type: 'public-key'
|
||
})),
|
||
authenticatorSelection: {
|
||
residentKey: 'preferred',
|
||
userVerification: 'preferred'
|
||
}
|
||
});
|
||
|
||
// Challenge speichern
|
||
await db.user.update({
|
||
where: { id: userId },
|
||
data: { currentChallenge: options.challenge }
|
||
});
|
||
|
||
return options;
|
||
}
|
||
|
||
export async function finishPasskeyRegistration(
|
||
userId: string,
|
||
response: RegistrationResponseJSON
|
||
) {
|
||
const user = await db.user.findUnique({ where: { id: userId } });
|
||
if (!user?.currentChallenge) throw new Error('No challenge');
|
||
|
||
const verification = await verifyRegistrationResponse({
|
||
response,
|
||
expectedChallenge: user.currentChallenge,
|
||
expectedOrigin: origin,
|
||
expectedRPID: rpID
|
||
});
|
||
|
||
if (verification.verified && verification.registrationInfo) {
|
||
await db.credential.create({
|
||
data: {
|
||
userId,
|
||
credentialId: verification.registrationInfo.credentialID,
|
||
publicKey: Buffer.from(verification.registrationInfo.credentialPublicKey),
|
||
counter: verification.registrationInfo.counter
|
||
}
|
||
});
|
||
}
|
||
|
||
return verification.verified;
|
||
}
|
||
|
||
// Authentication
|
||
export async function startPasskeyAuth(email: string) {
|
||
const user = await db.user.findUnique({
|
||
where: { email },
|
||
include: { credentials: true }
|
||
});
|
||
|
||
if (!user) throw new Error('User not found');
|
||
|
||
const options = await generateAuthenticationOptions({
|
||
rpID,
|
||
allowCredentials: user.credentials.map(c => ({
|
||
id: c.credentialId,
|
||
type: 'public-key'
|
||
})),
|
||
userVerification: 'preferred'
|
||
});
|
||
|
||
await db.user.update({
|
||
where: { id: user.id },
|
||
data: { currentChallenge: options.challenge }
|
||
});
|
||
|
||
return options;
|
||
}
|
||
|
||
export async function finishPasskeyAuth(
|
||
email: string,
|
||
response: AuthenticationResponseJSON
|
||
) {
|
||
const user = await db.user.findUnique({
|
||
where: { email },
|
||
include: { credentials: true }
|
||
});
|
||
|
||
if (!user?.currentChallenge) throw new Error('No challenge');
|
||
|
||
const credential = user.credentials.find(
|
||
c => c.credentialId === response.id
|
||
);
|
||
|
||
if (!credential) throw new Error('Unknown credential');
|
||
|
||
const verification = await verifyAuthenticationResponse({
|
||
response,
|
||
expectedChallenge: user.currentChallenge,
|
||
expectedOrigin: origin,
|
||
expectedRPID: rpID,
|
||
authenticator: {
|
||
credentialID: credential.credentialId,
|
||
credentialPublicKey: credential.publicKey,
|
||
counter: credential.counter
|
||
}
|
||
});
|
||
|
||
if (verification.verified) {
|
||
// Counter updaten
|
||
await db.credential.update({
|
||
where: { id: credential.id },
|
||
data: { counter: verification.authenticationInfo.newCounter }
|
||
});
|
||
|
||
return user;
|
||
}
|
||
|
||
throw new Error('Verification failed');
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// Client-Side (React)
|
||
'use client';
|
||
|
||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
|
||
|
||
function PasskeyLogin() {
|
||
const handleRegister = async () => {
|
||
const options = await fetch('/api/passkey/register/start', {
|
||
method: 'POST'
|
||
}).then(r => r.json());
|
||
|
||
const result = await startRegistration(options);
|
||
|
||
await fetch('/api/passkey/register/finish', {
|
||
method: 'POST',
|
||
body: JSON.stringify(result)
|
||
});
|
||
};
|
||
|
||
const handleLogin = async () => {
|
||
const options = await fetch('/api/passkey/login/start', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ email })
|
||
}).then(r => r.json());
|
||
|
||
const result = await startAuthentication(options);
|
||
|
||
await fetch('/api/passkey/login/finish', {
|
||
method: 'POST',
|
||
body: JSON.stringify(result)
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<button onClick={handleRegister}>Register Passkey</button>
|
||
<button onClick={handleLogin}>Login with Passkey</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Magic Links
|
||
|
||
```typescript
|
||
// Magic Link / Email Auth
|
||
import { Resend } from 'resend';
|
||
import crypto from 'crypto';
|
||
|
||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||
|
||
export async function sendMagicLink(email: string) {
|
||
const token = crypto.randomBytes(32).toString('hex');
|
||
const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 min
|
||
|
||
await db.verificationToken.create({
|
||
data: {
|
||
identifier: email,
|
||
token: await hash(token),
|
||
expires
|
||
}
|
||
});
|
||
|
||
const url = `${process.env.NEXTAUTH_URL}/api/auth/verify?token=${token}&email=${email}`;
|
||
|
||
await resend.emails.send({
|
||
from: 'noreply@example.com',
|
||
to: email,
|
||
subject: 'Login to My App',
|
||
html: `
|
||
<p>Click the link below to sign in:</p>
|
||
<a href="${url}">Sign in to My App</a>
|
||
<p>This link expires in 15 minutes.</p>
|
||
`
|
||
});
|
||
}
|
||
|
||
export async function verifyMagicLink(token: string, email: string) {
|
||
const storedToken = await db.verificationToken.findFirst({
|
||
where: {
|
||
identifier: email,
|
||
expires: { gt: new Date() }
|
||
}
|
||
});
|
||
|
||
if (!storedToken || !await verify(token, storedToken.token)) {
|
||
throw new Error('Invalid or expired token');
|
||
}
|
||
|
||
await db.verificationToken.delete({
|
||
where: { id: storedToken.id }
|
||
});
|
||
|
||
const user = await db.user.upsert({
|
||
where: { email },
|
||
update: { emailVerified: new Date() },
|
||
create: { email, emailVerified: new Date() }
|
||
});
|
||
|
||
return user;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Security Best Practices
|
||
|
||
```typescript
|
||
// 1. Password Hashing
|
||
import bcrypt from 'bcryptjs';
|
||
|
||
const SALT_ROUNDS = 12;
|
||
|
||
export async function hashPassword(password: string) {
|
||
return bcrypt.hash(password, SALT_ROUNDS);
|
||
}
|
||
|
||
// 2. CSRF Protection (automatisch bei Auth.js)
|
||
|
||
// 3. Secure Cookie Settings
|
||
const cookieOptions = {
|
||
httpOnly: true,
|
||
secure: process.env.NODE_ENV === 'production',
|
||
sameSite: 'lax' as const,
|
||
path: '/',
|
||
maxAge: 60 * 60 * 24 * 7 // 7 Tage
|
||
};
|
||
|
||
// 4. Rate Limiting für Auth Endpoints
|
||
// Siehe API Design Patterns Artikel
|
||
|
||
// 5. Account Lockout
|
||
async function checkLoginAttempts(email: string) {
|
||
const attempts = await db.loginAttempt.count({
|
||
where: {
|
||
email,
|
||
success: false,
|
||
createdAt: { gt: new Date(Date.now() - 15 * 60 * 1000) }
|
||
}
|
||
});
|
||
|
||
if (attempts >= 5) {
|
||
throw new Error('Account temporarily locked. Try again later.');
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Fazit
|
||
|
||
Authentication 2026:
|
||
|
||
1. **Auth.js v5**: Standard für Next.js
|
||
2. **Passkeys**: Die Zukunft des Logins
|
||
3. **JWT + Refresh**: Für APIs
|
||
4. **MFA**: Immer aktivieren wenn möglich
|
||
|
||
Wähle basierend auf Security-Anforderungen und UX.
|
||
|
||
---
|
||
|
||
## Bildprompts
|
||
|
||
1. "Multiple authentication methods merging into single secure access, login concept"
|
||
2. "Passkey biometric authentication on device, passwordless future"
|
||
3. "Security layers protecting user identity, authentication shield"
|
||
|
||
---
|
||
|
||
## Quellen
|
||
|
||
- [Auth.js Documentation](https://authjs.dev/)
|
||
- [WebAuthn Guide](https://webauthn.guide/)
|
||
- [SimpleWebAuthn](https://simplewebauthn.dev/)
|
||
- [OWASP Authentication Cheatsheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|