Authentication
DropBilko Authentication System& Authorization
Sources:Status:src/drop-app/src/app/api/auth/bankid/,Finalsrc/drop-api/src/lib/bankid.ts,Version:1.0 Date: 2026-02-25 Author: Platform Architectsrc/drop-api/src/routes/auth.ts
OverviewPurpose
DropThis usesdocument BankID OIDC asspecifies the sole authentication method.and Email/passwordauthorization login has been removed to comply with PSD2/SCA requirements.
Auth method:BankID OIDC (Norwegian eID)JWT Algorithm:HS256 (HMAC-SHA256), RS256 opt-inLibrary:jose(SignJWT/jwtVerify)Token lifetime:24h (web cookie), 7d (mobile Bearer token)Web cookie:drop_token(httpOnly, secure, sameSite=strict)Mobile:Bearer token in Authorization header
Phase 2 (planned)
Vipps Login — same OIDC pattern, user dedup bynational_id_hashIdura aggregator optional (single integration pointsystem forBankIDBilko's+backend.Vipps)Covers
Authentication Flow
BankID1. Login (Web)Registration
Endpoint:
Browser Next.js BFF BankID OIDC | | | | GETPOST /api/auth/bankid | | |--------------------------->| | | | 1. Rate limit check | | | 2. Generate state + nonce | | | 3. Set bankid_state cookie | | { redirectUrl } | | |<---------------------------| | | | | | Browser redirects to BankID authorize URL | |---------------------------------------------------------->| | | | | User authenticates with BankID | | | | | BankID redirects to /api/auth/bankid/callback?code=&state= |<----------------------------------------------------------| | | | | GET /callback?code&state | | |--------------------------->| | | | 4. Verify state vs cookie | | | 5. Exchange code for tokens | | |----------------------------->| | | { id_token, access_token } | | |<-----------------------------| | | 6. Verify ID token (JWKS) | | | 7. Parse pid, verify age | | | 8. Find/create user | | | 9. Create session + cookie | | 302 /dashboard | | |<---------------------------| |
BankID Login (Mobile)
Mobile App Hono API BankID OIDC
| | |
| GET /v1/auth/bankid/initiate?platform=mobile |
|--------------------------->| |
| { redirectUrl, state } | |
|<---------------------------| |
| | |
| Open BankID in secure browser (expo-web-browser) |
|---------------------------------------------------------->|
| | |
| User authenticates with BankID |
| | |
| Redirect to drop://auth/callback?code=&state= |
|<----------------------------------------------------------|
| | |
| POST /v1/auth/bankid/callback |
| { code, state, platform } |
|--------------------------->| |
| | 1. Exchange code for tokens |
| |----------------------------->|
| | { id_token } |
| |<-----------------------------|
| | 2. Verify ID token (JWKS) |
| | 3. Parse pid, verify age |
| | 4. Find/create user |
| | 5. Create session |
| { token, data } | |
|<---------------------------| |
| | |
| Store token in AsyncStorage |
registerUser Creation
BankID login automatically creates user accounts:Steps:
ParseValidatepidrequestfrom BankID ID tokenbody (Norwegianemailnationaluniqueness,ID,password11strength,digits)country/currency codes)- Hash
pidpassword withSHA-256 for storagebcrypt (12national_id_hashcolumn)rounds) Check existinguser bynational_id_hashIf new:Createuserdatabasewith:transaction:kyc_statusCreate Organization- Create User (role = '
approved'(BankID = verified identity)owner') kyc_methodCreate=default'bankid'Chart auth_providerof= 'bankid'Accounts (password_hash = 'EIDONLY'sentinelseed—accountsnobasedpasswordonauth)country)
AgeGeneratecheck:JWTMustaccessbe >= 18token (parsed15 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 pid'bcrypt'
birthdate)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 StructureTokens
PayloadAccess Token
Purpose: Short-lived token for API authentication.
Claims:
interface JwtPayloadAccessTokenPayload {
userId:sub: string;string // e.g.,User "usr_a1b2c3d4e5f6g7h8"ID email:(UUID)
string;type: 'access' // e.g.,Required "[email protected]"token role:class string;guard; non-access or missing type is rejected
email: string // "user"User oremail
"merchant"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:
ClaimsRefresh 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
owner |
|
admin |
|
accountant |
Create/edit |
viewer |
Read-only |
Permission Session Revocation
On login: sessions record created with SHA-256 hash of JWT
On each request: Verify session not revoked + not expired
On logout: All user sessions marked revoked = 1
CSRF Protection
Web: State parameter in BankID OIDC flow (stored in httpOnly cookie)
API: Origin header validation against allowed origins
Mobile: N/A (Bearer token, no cookies)
Rate Limiting
Matrix
sessionsrevoked = 1| admin | accountant | viewer | ||
|---|---|---|---|---|
| ✅ | ✅ | ❌ | ||
| ✅ | ✅ | ❌ | ||
| ✅ | ✅ | ❌ | ||
| 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 Authorization
Role-Based AccessImplementation
TwoRole roles: user and merchant.Guard:
DeprecatedSession EndpointsManagement
Session Storage
TheseOption endpoints1: 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 410res.status(401).json({ Gone:error: 'Unauthorized', code: 'NO_TOKEN' Endpoint })
Replacement }
const token =
POSTauthHeader.substring(7) /auth/login / BankIDRemove OIDC'Bearer flow '
try {
POSTconst payload = jwt.verify(token, process.env.JWT_SECRET!) as AccessTokenPayload
/auth/register / AutomaticAttach viauser BankIDto login request
req.user = POST{
/auth/verify-otp id: Notpayload.sub,
neededemail: payload.email,
role: payload.role,
organizationId: payload.orgId,
}
next()
} catch (BankIDerror) replaces{
OTP) 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
Required (Production)
BANKID_CLIENT_ID # BankID OIDC client ID
BANKID_CLIENT_SECRET # BankID OIDC client secret
BANKID_CALLBACK_URL # Web callback URL (e.g., https://getdrop.no/api/auth/bankid/callback)
BANKID_CALLBACK_URL_MOBILE # Mobile deep link (e.g., drop://auth/callback)
JWT_SECRET # JWT
signingJWT_SECRET=<256-bit secret (minfor 32access chars)tokens>
JWT_REFRESH_SECRET=<256-bit Optional
secret BANKID_AUTHORIZE_URLfor refresh tokens>
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Default:Rate BankIDLimiting
prod authorize endpoint
BANKID_TOKEN_URLRATE_LIMIT_AUTH=5 # Default:Max BankIDlogin prodattempts tokenper endpointminute
BANKID_JWKS_URLRATE_LIMIT_GENERAL=100 # Default:Max BankIDrequests prodper JWKS endpoint
BANKID_ISSUERminute
# Default:Session
BankID prod issuer
BANKID_MOCK=SESSION_COOKIE_SECURE=true # DevHTTPS mode: mock OIDC flowonly (noproduction)
real BankID needed)
JWT_ALGORITHM # "HS256" (default) or "RS256"
JWT_EXPIRY # Default: "24h"SESSION_COOKIE_SAMESITE=strict
Merchant
End Flowof MerchantsAuthentication use the same BankID login as regular users. After logging in:Documentation