07 — Security
Dokumen ini menjelaskan strategi keamanan backend ELJoy — dari authentication, data protection, sampai abuse prevention.
1. Authentication & Authorization
Auth Flow: Email + Password
User Frontend API Server Database
│ │ │ │
│ ── Input email+pass ──►│ │ │
│ │ ── POST /auth/ │ │
│ │ sign-up/email ────►│ │
│ │ │ ── Hash password │
│ │ │ (Argon2id) ──────►│
│ │ │ │
│ │ │ ── Create session │
│ │ │ token (random │
│ │ │ 256-bit) ─────────►│
│ │ │ │
│ │ ◄── Set-Cookie: │ │
│ │ eljoy_session= │ │
│ │ {token} │ │
│ │ HttpOnly │ │
│ │ Secure │ │
│ │ SameSite=Lax │ │
│ ◄──────────────────────│ Path=/ │ │
│ │ Max-Age=604800 │ │
Auth Flow: Google OAuth
User Frontend Google API Server Database
│ │ │ │ │
│ ── Klik ─────►│ │ │ │
│ "Login │ ── Redirect ──►│ │ │
│ Google" │ │ │ │
│ │ │ │ │
│ ◄─ Login di Google ───────────►│ │ │
│ │ │ │ │
│ │ ◄── id_token ──│ │ │
│ │ │ │ │
│ │ ── POST /auth/ │ │ │
│ │ sign-in/ │ │ │
│ │ social │ │ │
│ │ {provider: │ │ │
│ │ "google", │ │ │
│ │ idToken} ──────────────────►│ │
│ │ │ │ │
│ │ │ │ ── Verify token │
│ │ │ │ with Google ──►│
│ │ │ │ ◄── Valid ────────│
│ │ │ │ │
│ │ │ │ ── Find/Create │
│ │ │ │ user ──────────►
│ │ │ │ ── Create session ►
│ │ │ │ │
│ │ ◄── Set-Cookie ─────────────────│ │
│ ◄─────────────│ │ │ │
Session Security
| Property | Value | Alasan |
|---|---|---|
HttpOnly | true | Cegah JavaScript access (XSS protection) |
Secure | true | Hanya dikirim via HTTPS |
SameSite | Lax | Cegah CSRF, tapi tetap allow normal navigation |
Path | / | Available di semua route |
Max-Age | 604800 (7 hari) | Session expiry |
| Token format | Random 256-bit | Tidak predictable, tidak bisa brute-force |
Authorization Levels
// Middleware chain
// Level 1: Public — siapa saja bisa akses
app.get('/api/v1/content/domains', publicHandler);
// Level 2: Authenticated — harus login
app.get('/api/v1/users/me', authMiddleware, userHandler);
// Level 3: Member — harus login + subscription aktif
app.post('/api/v1/conversations', authMiddleware, memberMiddleware, chatHandler);
// Level 4: Admin — harus login + role admin
app.post('/api/v1/admin/content', authMiddleware, adminMiddleware, adminHandler);
// Member middleware
const memberMiddleware = async (c: Context, next: Next) => {
const user = c.get('user');
// Check active subscription
const subscription = await db.query.subscriptions.findFirst({
where: and(
eq(subscriptions.userId, user.id),
eq(subscriptions.status, 'active'),
gt(subscriptions.expiresAt, new Date()),
),
});
if (!subscription) {
throw new ForbiddenError('Fitur ini hanya untuk Member. Upgrade sekarang!');
}
await next();
};
2. Data Protection
Password Hashing
Better Auth menggunakan Argon2id secara default — algoritma hashing paling aman saat ini:
| Property | Value |
|---|---|
| Algorithm | Argon2id |
| Memory | 64MB |
| Iterations | 3 |
| Parallelism | 1 |
Password yang di-hash tidak bisa di-reverse (one-way), bahkan oleh admin.
Encryption
| Data | At Rest | In Transit |
|---|---|---|
| Password | Argon2id hash | HTTPS (TLS 1.3) |
| Session tokens | Plain in DB (random, no info) | HTTPS + HttpOnly cookie |
| User data | PostgreSQL encryption | HTTPS |
| Audio files | R2 server-side encryption | HTTPS |
| API keys | Environment variables | Never in code/logs |
| Payment data | Tidak disimpan (handled by Midtrans) | HTTPS + PCI DSS |
Sensitive Data Rules
- Password — Hanya disimpan sebagai hash, tidak pernah di-log
- API keys — Hanya di environment variables, tidak di kode atau database
- Payment info — Tidak disimpan di server kita, semua dihandle Midtrans (PCI compliant)
- Audio recordings — Disimpan di R2 dengan TTL, dihapus setelah 90 hari
- AI conversations — Disimpan di database, bisa dihapus user (right to erasure)
3. Input Validation & Sanitization
Request Validation (Zod)
Semua input user di-validasi sebelum diproses:
import { zValidator } from '@hono/zod-validator';
// Contoh: Register endpoint
app.post(
'/auth/sign-up/email',
zValidator('json', z.object({
email: z.string()
.email('Email tidak valid')
.max(255)
.transform(v => v.toLowerCase().trim()),
password: z.string()
.min(8, 'Password minimal 8 karakter')
.max(128)
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Password harus mengandung huruf besar, huruf kecil, dan angka'),
name: z.string()
.min(2, 'Nama minimal 2 karakter')
.max(100)
.trim(),
guestId: z.string().optional(),
})),
handler,
);
SQL Injection Prevention
Drizzle ORM menggunakan parameterized queries secara default — SQL injection tidak mungkin:
// ✅ AMAN — Drizzle auto-parameterize
const user = await db.query.users.findFirst({
where: eq(users.email, userInput), // userInput di-parameterize
});
// ✅ AMAN — Raw SQL tetap parameterized
const result = await db.execute(
sql`SELECT * FROM users WHERE email = ${userInput}`
);
// ❌ JANGAN PERNAH — String concatenation
// const result = await db.execute(`SELECT * FROM users WHERE email = '${userInput}'`);
XSS Prevention
- Semua output di-encode oleh React (frontend) secara default
- API hanya return JSON (bukan HTML) → XSS di API layer tidak mungkin
- Content-Type header selalu
application/json - Tidak ada server-side rendering (SSR)
4. Rate Limiting & Abuse Prevention
Rate Limit Tiers
const rateLimits = {
// Auth endpoints — ketat untuk cegah brute force
'auth:sign-in': { limit: 5, windowMs: 15 * 60 * 1000 }, // 5/15min
'auth:sign-up': { limit: 3, windowMs: 60 * 60 * 1000 }, // 3/1hr
'auth:forgot': { limit: 3, windowMs: 60 * 60 * 1000 }, // 3/1hr
// Assessment — mencegah spam assessment
'assessment': { limit: 5, windowMs: 60 * 1000 }, // 5/1min
// Conversation — normal speaking pace
'conversation': { limit: 30, windowMs: 60 * 1000 }, // 30/1min
// General API — generous for normal use
'general': { limit: 100, windowMs: 60 * 1000 }, // 100/1min
// File upload — prevent abuse
'upload': { limit: 10, windowMs: 60 * 1000 }, // 10/1min
};
IP-based vs User-based
// Untuk public endpoints (belum login): rate limit per IP
const key = `rl:${path}:ip:${getClientIP(c)}`;
// Untuk authenticated endpoints: rate limit per user
const key = `rl:${path}:user:${userId}`;
Anti-Abuse Measures
- Audio file size limit — Max 10MB per upload
- Assessment cooldown — Min 1 menit antara assessment sessions
- Free tier quotas — 1x Ice Breaker + 1x Universal Assessment
- Webhook signature verification — Midtrans webhooks diverifikasi
- CORS restriction — Hanya allow dari domain frontend yang dikenal
5. CORS Configuration
import { cors } from 'hono/cors';
app.use(cors({
origin: [
'https://app.eljoy.id', // Production frontend
'https://staging.eljoy.id', // Staging frontend
...(process.env.NODE_ENV === 'development'
? ['http://localhost:5173', 'http://localhost:3000']
: []),
],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'],
credentials: true, // Untuk cookies
maxAge: 86400, // Preflight cache 24 jam
}));
6. Security Headers
app.use(async (c, next) => {
await next();
// Security headers
c.header('X-Content-Type-Options', 'nosniff');
c.header('X-Frame-Options', 'DENY');
c.header('X-XSS-Protection', '0'); // Disabled, rely on CSP instead
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
c.header('Permissions-Policy', 'camera=(), geolocation=(), microphone=()');
// Strict-Transport-Security (HSTS)
if (process.env.NODE_ENV === 'production') {
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
});
7. Audit Logging
Semua aksi penting dicatat di audit_logs:
async function auditLog(params: {
actorId: string | null;
actorType: 'user' | 'admin' | 'system';
action: string;
resource: string;
resourceId?: string;
details?: Record<string, unknown>;
ipAddress?: string;
}) {
await db.insert(auditLogs).values({
id: nanoid(),
...params,
createdAt: new Date(),
});
}
// Contoh usage
await auditLog({
actorId: userId,
actorType: 'user',
action: 'user.register',
resource: 'user',
resourceId: newUser.id,
details: { method: 'email', guestLinked: true },
ipAddress: c.req.header('x-forwarded-for'),
});
Aksi yang di-audit:
user.register,user.login,user.logout,user.deleteassessment.start,assessment.completesubscription.create,subscription.cancelpayment.received,payment.failedcontent.create,content.update,content.delete(admin)admin.login,admin.user_action
8. Privacy & Data Retention
Data Retention Policy
| Data | Retention | Alasan |
|---|---|---|
| User account | Sampai dihapus user | Core data |
| Auth sessions | 7 hari (auto-expire) | Session lifecycle |
| Assessment results | Unlimited | Tracking progress |
| Conversation messages | 1 tahun | Review & improvement |
| Audio recordings (user) | 90 hari | Storage cost |
| Audio recordings (TTS) | 30 hari cache | Re-generate jika perlu |
| Audit logs | 2 tahun | Compliance |
| Deleted accounts | 30 hari (grace period), lalu purge | Right to erasure |
User Data Rights
- Right to Access — User bisa export semua data mereka via
GET /users/me/export - Right to Erasure — User bisa hapus akun via
DELETE /users/me - Right to Rectification — User bisa update data mereka via
PATCH /users/me - Data Portability — Export dalam format JSON
Account Deletion Flow
User request delete → Soft delete (30 days grace) → Hard delete (purge)
Soft Delete:
- User login disabled
- Data tetap ada (recoverable)
- Email: "Akunmu akan dihapus permanen dalam 30 hari"
Hard Delete (30 hari kemudian):
- Semua user data dihapus dari DB
- Audio files dihapus dari R2
- Audit log tetap (anonymized)
- Subscription di-cancel
9. Security Checklist
| # | Item | Status |
|---|---|---|
| 1 | Passwords hashed with Argon2id | ☐ |
| 2 | Sessions stored server-side (not JWT) | ☐ |
| 3 | Cookies: HttpOnly + Secure + SameSite | ☐ |
| 4 | HTTPS enforced (HSTS) | ☐ |
| 5 | CORS restricted to known origins | ☐ |
| 6 | Input validation on all endpoints (Zod) | ☐ |
| 7 | Parameterized queries (Drizzle ORM) | ☐ |
| 8 | Rate limiting on auth endpoints | ☐ |
| 9 | Rate limiting on AI endpoints | ☐ |
| 10 | File upload size limits | ☐ |
| 11 | Webhook signature verification | ☐ |
| 12 | Security headers set | ☐ |
| 13 | Audit logging implemented | ☐ |
| 14 | No secrets in code/logs | ☐ |
| 15 | Data retention policy enforced | ☐ |
| 16 | Account deletion (soft + hard) | ☐ |
| 17 | Error messages don't leak internals | ☐ |
| 18 | Dependencies regularly updated | ☐ |