Authentication
Bilko Authentication & Authorization
Status: Final Version: 1.0 Date: 2026-02-25 Author: Platform Architect
Purpose
This document specifies the authentication and authorization system for Bilko's backend. Covers JWT tokens, password hashing, 2FA, role-based access control (RBAC), and session management.
Authentication Flow
1. Registration
Endpoint: POST /api/v1/auth/register
Steps:
- Validate request body (email uniqueness, password strength, country/currency codes)
- Hash password with bcrypt (12 rounds)
- Create database transaction:
- Create Organization
- Create User (role = 'owner')
- Create default Chart of Accounts (seed accounts based on country)
- Generate JWT access token (15 min expiry)
- Generate refresh token (7 days expiry)
- Set refresh token in httpOnly cookie
- Return user + organization + tokens
Password Requirements:
- Minimum 8 characters
- At least 1 uppercase letter
- At least 1 lowercase letter
- At least 1 number
- Optional: 1 special character
Password Hashing:
import bcrypt from 'bcrypt'
const SALT_ROUNDS = 12
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
Errors:
400 Bad Request— Email already exists422 Unprocessable Entity— Weak password, invalid country code
2. Login
Endpoint: POST /api/v1/auth/login
Steps:
- Find user by email
- Verify password with bcrypt.compare()
- If 2FA enabled, send TOTP challenge (not covered in MVP)
- Update user.lastLoginAt
- Generate JWT access token (15 min expiry)
- Generate refresh token (7 days)
- Set refresh token in httpOnly cookie
- Return user + tokens
Rate Limiting:
- Max 5 login attempts per 1 minute per IP address
- After 5 failed attempts, return
429 Too Many Requests - Lockout duration: 15 minutes
Errors:
3. Token Refresh
Endpoint: POST /api/v1/auth/refresh
Steps:
- Extract refresh token from httpOnly cookie
- Verify refresh token signature
- Check if token is blacklisted (revoked)
- Check expiry
- Generate new access token (15 min expiry)
- Return new access token
Refresh Token Storage:
- Stored in httpOnly cookie (prevents XSS attacks)
- Secure flag = true (HTTPS only)
- Cookie name =
refreshToken - SameSite =
Noneby default for Cloud Run web/API cross-origin requests (SESSION_COOKIE_SAMESITEmay override) - Path =
/api/v1/auth
Token Revocation:
- On logout, add refresh token to blacklist (Redis or PostgreSQL)
- Blacklist stores token JTI (JWT ID) + expiry
- Expired blacklist entries auto-deleted after 30 days
Errors:
4. Logout
Endpoint: POST /api/v1/auth/logout
Steps:
- Extract refresh token from cookie
- Add token JTI to blacklist
- Clear httpOnly cookie
- Return 204 No Content
JWT Tokens
Access Token
Purpose: Short-lived token for API authentication.
Claims:
interface AccessTokenPayload {
sub: string // User ID (UUID)
type: 'access' // Required token class guard; non-access or missing type is rejected
email: string // User email
role: UserRole // owner, admin, accountant, viewer
orgId: string // Organization ID (UUID)
iat: number // Issued at (Unix timestamp)
exp: number // Expires at (Unix timestamp, iat + 15 min)
}
Expiry: 15 minutes
Issuer/Audience: iss=bilko-api, aud=bilko-app
Header:
{
"alg": "HS256",
"typ": "JWT"
}
Usage:
Refresh Token
Purpose: Long-lived token for obtaining new access tokens.
Claims:
interface RefreshTokenPayload {
sub: string // User ID (UUID)
jti: string // JWT ID (for revocation)
iat: number // Issued at (Unix timestamp)
exp: number // Expires at (Unix timestamp, iat + 7 days)
}
Expiry: 7 days
Issuer/Audience: iss=bilko-api, aud=bilko-app
Storage:
Revocation:
- On logout, JTI added to blacklist
- On password change, all refresh tokens revoked
- On user deletion, all refresh tokens revoked
JWT Secret Management
CRITICAL: JWT secret MUST be stored securely.
Environment Variables:
# .env
JWT_SECRET=<256-bit random string, minimum 32 chars>
JWT_REFRESH_SECRET=<different 256-bit random string>
Generation:
# Generate secure random secret
openssl rand -base64 32
Best Practices:
- Use different secrets for access and refresh tokens
- Rotate secrets every 90 days (requires re-login for all users)
- Store in environment variables, NEVER in code
- Use Vaultwarden or similar secret manager in production
Two-Factor Authentication (2FA)
Status: OPTIONAL in MVP, implement in v2
Flow:
- User enables 2FA in settings
- Generate TOTP secret (32-char base32 string)
- Display QR code (Google Authenticator, Authy compatible)
- User scans QR code
- User enters 6-digit code to verify
- Store
twoFactorSecret(encrypted) inuserstable - Set
twoFactorEnabled = true
Login with 2FA:
- User enters email + password
- If
twoFactorEnabled = true, return403withrequiresTwoFactor: true - Frontend prompts for 6-digit code
- User submits code via
POST /api/v1/auth/verify-2fa - Verify TOTP code (30-second window)
- If valid, issue tokens
TOTP Verification:
import speakeasy from 'speakeasy'
function verifyTOTP(secret: string, token: string): boolean {
return speakeasy.totp.verify({
secret,
encoding: 'base32',
token,
window: 1, // Allow 1 time step before/after (30s window)
})
}
Role-Based Access Control (RBAC)
Roles
| Role | Permissions |
|---|---|
| owner | Full access: manage users, delete organization, change settings, all financial operations |
| admin | Manage users (except owner), change settings, all financial operations |
| accountant | Create/edit invoices, expenses, transactions. View reports. Cannot manage users or settings. |
| viewer | Read-only access to all financial data. Cannot create or edit. |
Permission Matrix
| Action | owner | admin | accountant | viewer |
|---|---|---|---|---|
| Create invoice | ✅ | ✅ | ✅ | ❌ |
| Edit invoice (draft) | ✅ | ✅ | ✅ | ❌ |
| Send invoice | ✅ | ✅ | ✅ | ❌ |
| Mark invoice paid | ✅ | ✅ | ✅ | ❌ |
| Create expense | ✅ | ✅ | ✅ | ❌ |
| Approve expense | ✅ | ✅ | ❌ | ❌ |
| Create manual transaction | ✅ | ✅ | ✅ | ❌ |
| View reports | ✅ | ✅ | ✅ | ✅ |
| Invite user | ✅ | ✅ | ❌ | ❌ |
| Change user role | ✅ | ❌ | ❌ | ❌ |
| Delete user | ✅ | ❌ | ❌ | ❌ |
| Update org settings | ✅ | ✅ | ❌ | ❌ |
| Delete organization | ✅ | ❌ | ❌ | ❌ |
Middleware Implementation
Role Guard:
import { Request, Response, NextFunction } from 'express'
type UserRole = 'owner' | 'admin' | 'accountant' | 'viewer'
function roleGuard(allowedRoles: UserRole[]) {
return (req: Request, res: Response, next: NextFunction) => {
const user = req.user // Attached by authGuard middleware
if (!user) {
return res.status(401).json({ error: 'Unauthorized', code: 'NO_AUTH' })
}
if (!allowedRoles.includes(user.role)) {
return res.status(403).json({
error: 'Forbidden',
code: 'INSUFFICIENT_PERMISSIONS',
details: { required: allowedRoles, current: user.role },
})
}
next()
}
}
// Usage in routes
app.post('/api/v1/invoices', authGuard, roleGuard(['owner', 'admin', 'accountant']), createInvoice)
Session Management
Session Storage
Option 1: JWT-only (stateless, recommended for MVP):
- No server-side session storage
- All state in JWT claims
- Fast, scales horizontally
- Cannot revoke access tokens (must wait for expiry)
Option 2: Redis sessions (for v2):
- Store session data in Redis
- JWT contains only session ID
- Can revoke immediately
- Requires Redis infrastructure
Recommended for MVP: JWT-only (stateless)
Session Invalidation
On password change:
- Hash new password
- Update
users.passwordHash - Delete all refresh tokens from blacklist older than 1 hour (force re-login)
- Return success
On account deletion:
- Soft-delete user (set
isActive = false) - Add all user's refresh tokens to blacklist
- Revoke access immediately
Security Best Practices
1. Password Storage
- NEVER store plain text passwords
- Use bcrypt with 12 rounds (2^12 iterations)
- Bcrypt auto-salts (no need to store salt separately)
2. Token Security
- Access tokens in
Authorizationheader (NOT cookies to avoid CSRF) - Refresh tokens in httpOnly cookies (prevent XSS)
- Use Secure flag (HTTPS only)
- Use
SameSite=None; Securefor the current Cloud Run web/API cross-origin deployment; override withSESSION_COOKIE_SAMESITE=Strictonly when same-site hosting is restored
3. Rate Limiting
- Login: 5 attempts per minute per IP
- Register: 5 attempts per minute per IP
- Refresh: 100 attempts per minute per user
- All other endpoints: 100 requests per minute per user
4. HTTPS Only
- All traffic over HTTPS in production
- Redirect HTTP → HTTPS
- HSTS header:
Strict-Transport-Security: max-age=31536000; includeSubDomains
5. CORS Configuration
const corsOptions = {
origin: ['https://bilko.io', 'http://localhost:3000'],
credentials: true, // Allow cookies
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}
app.use(cors(corsOptions))
6. Input Validation
- Validate all inputs with Zod schemas
- Sanitize SQL inputs (Prisma prevents SQL injection)
- Escape HTML in user-generated content
Example Implementation
Auth Middleware
import jwt from 'jsonwebtoken'
import { Request, Response, NextFunction } from 'express'
interface AuthRequest extends Request {
user?: {
id: string
email: string
role: UserRole
organizationId: string
}
}
async function authGuard(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized', code: 'NO_TOKEN' })
}
const token = authHeader.substring(7) // Remove 'Bearer '
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as AccessTokenPayload
// Attach user to request
req.user = {
id: payload.sub,
email: payload.email,
role: payload.role,
organizationId: payload.orgId,
}
next()
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' })
}
return res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' })
}
}
export { authGuard, roleGuard }
Environment Variables
# JWT
JWT_SECRET=<256-bit secret for access tokens>
JWT_REFRESH_SECRET=<256-bit secret for refresh tokens>
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Rate Limiting
RATE_LIMIT_AUTH=5 # Max login attempts per minute
RATE_LIMIT_GENERAL=100 # Max requests per minute
# Session
SESSION_COOKIE_SECURE=true # HTTPS only (production)
SESSION_COOKIE_SAMESITE=strict
End of Authentication Documentation