Authentication
BilkoDrop Authentication & AuthorizationSystem
Status:Sources:Finalsrc/drop-app/src/app/api/auth/bankid/,Version:src/drop-api/src/lib/bankid.ts,1.0Date:2026-02-25Author:Platform Architectsrc/drop-api/src/routes/auth.ts
PurposeOverview
ThisDrop documentuses specifiesBankID OIDC as the sole authentication andmethod. authorizationEmail/password systemlogin has been removed to comply with PSD2/SCA requirements.
- Auth method: BankID OIDC (Norwegian eID)
- JWT Algorithm: HS256 (HMAC-SHA256), RS256 opt-in
- Library:
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 by
national_id_hash - Idura aggregator optional (single integration point for
Bilko'sBankIDbackend.+CoversVipps)
Authentication Flow
BankID Login (Web)
Browser Next.js BFF BankID OIDC
| | |
| GET /api/auth/bankid | |
|--------------------------->| |
| | 1. RegistrationRate 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)
Endpoint:
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 /api/v1/auth/registerbankid/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 |
User Creation
Steps:BankID login automatically creates user accounts:
ValidateParserequestpidbodyfrom BankID ID token (emailNorwegianuniqueness,nationalpasswordID,strength,11country/currency codes)digits)- Hash
passwordpid withbcryptSHA-256 for storage (12national_id_hashrounds)column) - Check existing user by
national_id_hash - If new: Create
databaseusertransaction:with:Create OrganizationCreate User (rolekyc_status = '(BankID = verified identity)owner')approved'Createkyc_methoddefault=Chart'bankid'auth_providerAccounts= 'bankid'password_hash = 'EIDONLY'(seedsentinelaccounts—basednoonpasswordcountry)auth)
of GenerateAgeJWTcheck:accessMusttokenbe >= 18 (15 min expiry)Generate refresh token (7 days expiry)Set refresh token in httpOnly cookieReturn user + organization + tokens
Password Requirements:
Minimum 8 charactersAt least 1 uppercase letterAt least 1 lowercase letterAt least 1 numberOptional: 1 special character
Password Hashing:
import bcryptparsed from 'bcrypt'pid 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 emailVerify password with bcrypt.compare()If 2FA enabled, send TOTP challenge (not covered in MVP)Update user.lastLoginAtGenerate JWT access token (15 min expiry)Generate refresh token (7 days)Set refresh token in httpOnly cookieReturn user + tokens
Rate Limiting:
Max 5 login attempts per 1 minute per IP addressAfter 5 failed attempts, return429 Too Many RequestsLockout duration: 15 minutes
Errors:
3. Token Refresh
Endpoint: POST /api/v1/auth/refresh
Steps:
Extract refresh token from httpOnly cookieVerify refresh token signatureCheck if token is blacklisted (revoked)Check expiryGenerate 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 =refreshTokenSameSite =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) + expiryExpired blacklist entries auto-deleted after 30 days
Errors:
4. Logout
Endpoint: POST /api/v1/auth/logout
Steps:
Extract refresh token from cookieAdd token JTI to blacklistClear httpOnly cookieReturn 204 No Contentbirthdate)
JWT TokensStructure
Access TokenPayload
Purpose: Short-lived token for API authentication.
Claims:
interface AccessTokenPayloadJwtPayload {
sub:userId: stringstring; // Usere.g., ID"usr_a1b2c3d4e5f6g7h8"
(UUID)email: type: 'access'string; // Requirede.g., token"[email protected]"
classrole: guard; non-access or missing type is rejected
email: stringstring; // User"user" emailor 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)"merchant"
}
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 blacklistOn password change, all refresh tokens revokedOn 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 tokensRotate secrets every 90 days (requires re-login for all users)Store in environment variables, NEVER in codeUse Vaultwarden or similar secret manager in production
Two-Factor Authentication (2FA)
Status: OPTIONAL in MVP, implement in v2
Flow:
User enables 2FA in settingsGenerate TOTP secret (32-char base32 string)Display QR code (Google Authenticator, Authy compatible)User scans QR codeUser enters 6-digit code to verifyStoretwoFactorSecret(encrypted) inuserstableSettwoFactorEnabled = true
Login with 2FA:
User enters email + passwordIftwoFactorEnabled = true, return403withrequiresTwoFactor: trueFrontend prompts for 6-digit codeUser submits code viaPOST /api/v1/auth/verify-2faVerify 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)
RolesClaims
exp |
|
iat |
|
iss |
drop-api |
aud |
drop |
Permission
Matrix
Session Revocation
- On login:
sessionsrecord 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
Authorization
MiddlewareRole-Based ImplementationAccess
RoleTwo Guard:roles: user and merchant.
import
{
Request,
Response,Route
NextFunctionAuth
}Role
from
'express'
type
UserRole
=GET 'owner'/auth/bankid/initiate
|None
'admin'-
|
'accountant'
|POST 'viewer'/auth/bankid/callback
functionNone
roleGuard(allowedRoles:-
UserRole[])
{
returnGET /auth/me
Required
Any
POST /auth/logout
Required
Any
POST /auth/refresh
Required
Any
POST /merchants/register
Required
Any (req:upgrades Request,to res:merchant)
Response,
next:
NextFunction) => {
const user = req.userGET //merchants/dashboard
AttachedRequired
byMerchant
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)
SessionDeprecated ManagementEndpoints
Session
These 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 Authorization header (NOT cookies to avoid CSRF)
Refresh tokens in httpOnly cookies (prevent XSS)
Use Secure flag (HTTPS only)
Use SameSite=None; Secure for the current Cloud Run web/API cross-origin deployment; override with SESSION_COOKIE_SAMESITE=Strict only 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 ')) {endpoints return res.status(401).json({410 error:Gone:
'Unauthorized',
code:
'NO_TOKEN'
})Endpoint
}Replacement
const
token
=
authHeader.substring(7)
POST //auth/login
RemoveBankID 'BearerOIDC 'flow
try
{
const payload = jwt.verify(token, process.env.JWT_SECRET!) as AccessTokenPayloadPOST /auth/register
Automatic via BankID login
POST /auth/verify-otp
AttachNot user to request
req.user = {
id: payload.sub,
email: payload.email,
role: payload.role,
organizationId: payload.orgId,
}
next()
} catchneeded (error)BankID {replaces ifOTP)
(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 JWT_SECRET=<256-bitsigning secret for(min access32 tokens>chars)
JWT_REFRESH_SECRET=<256-bit
secretOptional
for refresh tokens>
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7dBANKID_AUTHORIZE_URL # RateDefault: LimitingBankID RATE_LIMIT_AUTH=5prod authorize endpoint
BANKID_TOKEN_URL # MaxDefault: loginBankID attemptsprod pertoken minuteendpoint
RATE_LIMIT_GENERAL=100BANKID_JWKS_URL # MaxDefault: requestsBankID perprod minuteJWKS endpoint
BANKID_ISSUER # SessionDefault: SESSION_COOKIE_SECURE=BankID prod issuer
BANKID_MOCK=true # HTTPSDev onlymode: mock OIDC flow (production)no SESSION_COOKIE_SAMESITE=strictreal BankID needed)
JWT_ALGORITHM # "HS256" (default) or "RS256"
JWT_EXPIRY # Default: "24h"
Merchant Flow
EndMerchants ofuse Authenticationthe Documentationsame BankID login as regular users. After logging in: