Authentication
Two authentication mechanisms
The Racines application uses two distinct authentication systems depending on the role:
| Role | Mechanism | Session storage |
|---|---|---|
| Admin | Email + Password → JWT HS256 → HttpOnly Cookie | Secure cookie (7 days) |
| Speaker | Raw access code → DB lookup → React context | localStorage |
| Player | Per-language access code → DB lookup → localStorage | localStorage |
Admin Authentication
Password hashing (PBKDF2)
Passwords are never stored in plain text. The algorithm used is PBKDF2 (Password-Based Key Derivation Function 2).
Parameters:
- Hash algorithm: SHA256
- Iterations: 100,000
- Salt: random (generated by crypto.randomBytes(32))
- Derived key length: 64 bytes
Format stored in the password_hash column:
pbkdf2$100000$[salt_hex]$[hash_hex]
Comparison: constant-time comparison via crypto.timingSafeEqual() to prevent timing attacks.
Source code: src/lib/auth.ts
JWT generation
After password verification, a JWT token is generated:
// src/lib/auth.ts
const token = await new SignJWT({ admin_id: admin.id })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.sign(secret);
JWT payload:
- admin_id: UUID of the admin in the admins table
- exp: expiration timestamp (D+7)
Signing key: JWT_SECRET (environment variable, minimum 32 characters)
Session cookie
The token is stored in an HttpOnly cookie:
| Attribute | Value | Role |
|---|---|---|
| Name | auth-token |
Cookie identifier |
| HttpOnly | true |
Inaccessible from JavaScript (XSS protection) |
| Secure | true in production |
HTTPS only |
| SameSite | Lax |
Basic CSRF protection |
| Max-Age | 604800 (7 days) |
Automatic expiration |
| Path | / |
Valid on all routes |
Protection middleware
Admin routes are protected via wrappers in src/lib/auth-middleware.ts:
// Usage in an API route
export const GET = withAuth(async (request, { admin_id }) => {
// admin_id is verified and extracted from the JWT
return Response.json({ data: ... });
});
withAuth(handler): verifies the auth-token cookie, decodes the JWT, injects admin_id into the handler.
requireAuth(request): version for Server Components — returns the JWT payload or null.
Complete flow
1. POST /api/auth/login
Body: { email, password }
2. Email lookup → admins table
3. PBKDF2 password verification
4. If OK → SignJWT({ admin_id })
5. Set-Cookie: auth-token=...
6. Subsequent admin requests:
Header Cookie: auth-token=...
→ Middleware verifies JWT → extracts admin_id
Logout
POST /api/auth/logout → deletes the cookie server-side (Max-Age=0).
Speaker Authentication
The speaker authenticates via a raw access code (no JWT).
Flow
1. POST /api/speaker/login
Body: { accessCode: "RACINES-XXXXX-2025" }
2. SELECT * FROM speakers WHERE access_code = $1 AND is_active = true
3. If found → returns { id, name, languageId, languageCode, languageName, accessCode }
4. The client stores this data in localStorage (key: "speaker_session")
React context
src/contexts/SpeakerContext.tsx manages the speaker session state:
- Loading from localStorage at startup
- Exposing data via the useSpeakerContext() hook
- Logout: deletion from localStorage
Session format (localStorage):
{
"id": "speaker-uuid",
"name": "Amadou Diallo",
"languageId": "language-uuid",
"languageCode": "pul",
"languageName": "Pular",
"accessCode": "RACINES-XXXXX-2025"
}
Player Authentication (language unlocking)
1. POST /api/languages/verify-code
Body: { code: "RACINES-XXXXX-2025" }
2. SELECT id FROM languages WHERE access_code = $1 AND is_active = true
3. If found → returns { languageId }
4. The client stores the ID in localStorage (via LanguageContext)
src/contexts/LanguageContext.tsx manages unlocked languages:
- unlockedLanguages: array of unlocked language IDs (localStorage)
- isAdmin: boolean if the user is an admin (localStorage flag, not server-side secure for players)
- isLanguageUnlocked(id): returns true if the language is in unlockedLanguages or if isAdmin
Creating an administrator account
# On the server (SSH access required)
cd /app
npm run create-admin
# The script prompts for:
# - Email: admin@example.com
# - Password: [hidden]
# - Confirmation: [hidden]
#
# Generates the PBKDF2 hash and inserts it into the admins table
Source code: src/lib/create-admin.ts
Security summary
| Threat | Mitigation |
|---|---|
| SQL injection | Parameterized queries (no string concatenation) |
| XSS → token theft | HttpOnly cookie (JS cannot read the cookie) |
| CSRF | SameSite=Lax + Origin verification |
| Timing attacks | timingSafeEqual() comparison |
| Brute force | Rate limiting (to configure via Traefik or middleware) |
| Password storage | PBKDF2 100k iterations + random salt |