Skip to main content

Authentication

BilkoDrop Authentication & AuthorizationSystem

Status:Sources: Finalsrc/drop-app/src/app/api/auth/bankid/, Version:src/drop-api/src/lib/bankid.ts, 1.0 Date: 2026-02-25 Author: 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'sBankID backend.+ CoversVipps)
  • JWT tokens, password hashing, 2FA, role-based access control (RBAC), and session management.


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:

  1. ValidateParse requestpid bodyfrom BankID ID token (emailNorwegian uniqueness,national passwordID, strength,11 country/currency codes)digits)
  2. Hash passwordpid with bcryptSHA-256 for storage (12national_id_hash rounds)column)
  3. Check existing user by national_id_hash
  4. If new: Create databaseuser transaction:with:
    • Create Organization
    • Create User (rolekyc_status = 'owner')approved' (BankID = verified identity)
    • Createkyc_method default= Chart'bankid'
    • of
    • auth_provider Accounts= 'bankid'
    • password_hash = 'EIDONLY' (seedsentinel accounts basedno onpassword country)auth)
  5. GenerateAge JWTcheck: accessMust tokenbe >= 18 (15 min expiry)
  6. Generate refresh token (7 days expiry)
  7. Set refresh token in httpOnly cookie
  8. 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 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 exists
  • 422 Unprocessable Entity — Weak password, invalid country code

2. Login

Endpoint: POST /api/v1/auth/login

Steps:

  1. Find user by email
  2. Verify password with bcrypt.compare()
  3. If 2FA enabled, send TOTP challenge (not covered in MVP)
  4. Update user.lastLoginAt
  5. Generate JWT access token (15 min expiry)
  6. Generate refresh token (7 days)
  7. Set refresh token in httpOnly cookie
  8. 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:

  • 401 Unauthorized — Invalid email or password
  • 403 Forbidden — Account disabled or requires 2FA
  • 429 Too Many Requests — Rate limit exceeded

3. Token Refresh

Endpoint: POST /api/v1/auth/refresh

Steps:

  1. Extract refresh token from httpOnly cookie
  2. Verify refresh token signature
  3. Check if token is blacklisted (revoked)
  4. Check expiry
  5. Generate new access token (15 min expiry)
  6. Return new access token

Refresh Token Storage:

  • Stored in httpOnly cookie (prevents XSS attacks)
  • Secure flag = true (HTTPS only)
  • Cookie name = refreshToken
  • SameSite = None by default for Cloud Run web/API cross-origin requests (SESSION_COOKIE_SAMESITE may 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:

  • 401 Unauthorized — Invalid or expired refresh token

4. Logout

Endpoint: POST /api/v1/auth/logout

Steps:

  1. Extract refresh token from cookie
  2. Add token JTI to blacklist
  3. Clear httpOnly cookie
  4. Return 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:

  • Sent in Authorization: Bearer <token> header
  • Verified on every API request via authGuard middleware
  • If expired, client requests new token via /auth/refresh

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:

  1. User enables 2FA in settings
  2. Generate TOTP secret (32-char base32 string)
  3. Display QR code (Google Authenticator, Authy compatible)
  4. User scans QR code
  5. User enters 6-digit code to verify
  6. Store twoFactorSecret (encrypted) in users table
  7. Set twoFactorEnabled = true

Login with 2FA:

  1. User enters email + password
  2. If twoFactorEnabled = true, return 403 with requiresTwoFactor: true
  3. Frontend prompts for 6-digit code
  4. User submits code via POST /api/v1/auth/verify-2fa
  5. Verify TOTP code (30-second window)
  6. 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

RoleClaim PermissionsValue
ownerexp FullCurrent access:time manage+ users,24h delete(web) organization,/ change7d settings, all financial operations(mobile)
adminiat ManageCurrent users (except owner), change settings, all financial operationstime
accountantiss Create/editdrop-api invoices,(Hono) expenses,/ transactions.none View reports. Cannot manage users or settings.(Next.js)
vieweraud Read-onlydrop access(Hono) to/ allnone financial data. Cannot create or edit.(Next.js)

Permission
Matrix

Session Revocation

  1. On login: sessions record created with SHA-256 hash of JWT
  2. On each request: Verify session not revoked + not expired
  3. 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

10/minper 10/minper Noadditionallimit(auth
ActionEndpoint owneradminaccountantviewerLimit
CreateBankID invoiceinitiate IP
EditBankID invoice (draft)callback IP
SendAuth invoiceme/logout/refresh
Mark invoice paid
Create expense
Approve expense
Create manual transaction
View reports
Invite user
Change user role
Delete user
Update org settings
Delete organizationrequired)

Authorization

MiddlewareRole-Based ImplementationAccess

RoleTwo Guard:roles: user and merchant.

import{Request,Response,NextFunction}from'express'typeUserRole=|'admin'|'accountant'|functionroleGuard(allowedRoles:UserRole[]){return
















Response,next:NextFunction) => {
    const user = req.userAttachedbyauthGuardmiddlewareif (!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)
Route Auth Role
GET 'owner'/auth/bankid/initiate None -
POST 'viewer'/auth/bankid/callback None -
GET /auth/meRequiredAny
POST /auth/logoutRequiredAny
POST /auth/refreshRequiredAny
POST /merchants/registerRequiredAny (req:upgrades Request,to res:merchant)
GET //merchants/dashboard Required Merchant

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

Session Invalidation

On password change:

  1. Hash new password
  2. Update users.passwordHash
  3. Delete all refresh tokens from blacklist older than 1 hour (force re-login)
  4. Return success

On account deletion:

  1. Soft-delete user (set isActive = false)
  2. Add all user's refresh tokens to blacklist
  3. 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'})}consttoken=authHeader.substring(7) Removetry{const payload = jwt.verify(token, process.env.JWT_SECRET!) as AccessTokenPayload Attach(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 }
Endpoint Replacement
POST //auth/login BankID 'BearerOIDC 'flow
POST /auth/registerAutomatic via BankID login
POST /auth/verify-otp Not user to request req.user = { id: payload.sub, email: payload.email, role: payload.role, organizationId: payload.orgId, } next() } catchneeded (error)BankID {replaces ifOTP)

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
secret

Optional

for refresh tokens> JWT_ACCESS_EXPIRY=15m JWT_REFRESH_EXPIRY=7d
BANKID_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:

  1. Navigate to merchant registration
  2. Fill in business details (business name, org number, bank account)
  3. POST /merchants/register with auth token
  4. User role upgraded from user to merchant
  5. Merchant dashboard becomes accessible