API & Data
API Reference
Drop Backend API Reference
Auto-generated from source code analysis. All file references are relative to
src/drop-app/src/.
Overview
Drop uses Next.js App Router API routes (app/api/). All responses use a consistent JSON envelope:
{ "data": { ... } } // Success
{ "error": "code", "message": "...", "details": [...] } // Error
Authentication is via httpOnly cookie (drop_token) containing a signed JWT (HS256, 24h expiry).
Pass-Through Model: Drop uses a PSD2 pass-through model — it NEVER holds customer money. There is no wallet, no balance, no top-up. User funds remain in their bank account at all times. Drop uses:
- AISP (Account Information Service Provider) — reads bank balance via Open Banking
- PISP (Payment Initiation Service Provider) — initiates transfers directly from user's bank account
The bank_accounts.balance field stores the last AISP-read balance from the user's real bank (cached for display) — NOT a Drop-held balance.
Authentication
POST /api/auth/register
Create a new user account.
| Field | Source |
|---|---|
| File | app/api/auth/register/route.ts |
| Auth | None |
| Rate Limit | 10 req/min per IP |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| string | Yes | RFC-like regex, unique | |
| password | string | Yes | Min 8 chars, must contain letters + digits |
| firstName | string | Yes | validateName() — 1-100 chars, at least one letter, no HTML/script |
| lastName | string | Yes | Same as firstName |
| phone | string | No | International format +XXXXXXXXXXXX (8-15 digits) |
| dateOfBirth | string | Yes | ISO date string, must be >= 18 years old |
Success Response (201):
{
"data": {
"id": "usr_...",
"email": "user@example.com",
"firstName": "...",
"lastName": "...",
"dateOfBirth": "...",
"kycStatus": "pending",
"createdAt": "2026-..."
}
}
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | bad_request | Invalid JSON body |
| 409 | conflict | Email already registered |
| 422 | validation_error | Field validation failures (returned in details array) |
| 429 | rate_limited | Too many requests |
POST /api/auth/login
Authenticate with email and password.
| Field | Source |
|---|---|
| File | app/api/auth/login/route.ts |
| Auth | None |
| Rate Limit | 10 req/min per IP |
Request Body:
| Field | Type | Required |
|---|---|---|
| string | Yes | |
| password | string | Yes |
Success Response (200):
{
"data": {
"id": "usr_...",
"email": "...",
"firstName": "...",
"lastName": "...",
"kycStatus": "approved"
}
}
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | bad_request | Missing email or password |
| 401 | unauthorized | Invalid credentials |
| 429 | rate_limited | Too many requests |
GET /api/auth/me
Get current authenticated user with bank accounts.
| Field | Source |
|---|---|
| File | app/api/auth/me/route.ts |
| Auth | Required (cookie) |
Success Response (200):
{
"data": {
"id": "usr_...",
"email": "...",
"firstName": "...",
"lastName": "...",
"totalBalance": 58030.0,
"bankAccounts": [
{
"id": "ba_1",
"bankName": "DNB",
"accountNumber": "1234.56.78901",
"balance": 45230.0,
"currency": "NOK",
"isPrimary": true
}
],
"kycStatus": "approved",
"createdAt": "..."
}
}
POST /api/auth/logout
Logout and revoke all sessions.
| Field | Source |
|---|---|
| File | app/api/auth/logout/route.ts |
| Auth | Required (cookie) |
Calls revokeAllSessions() to invalidate all session records, then clears the auth cookie.
Success Response (200):
{ "message": "Logged out" }
POST /api/auth/refresh
Refresh the authentication token (issue new JWT, create new session record).
| Field | Source |
|---|---|
| File | app/api/auth/refresh/route.ts |
| Auth | Required (cookie) |
Success Response (200):
{
"data": {
"userId": "usr_...",
"email": "...",
"role": "user"
}
}
Transactions
GET /api/transactions
List user's transactions with pagination and filtering.
| Field | Source |
|---|---|
| File | app/api/transactions/route.ts |
| Auth | Required |
Query Parameters:
| Param | Type | Default | Notes |
|---|---|---|---|
| page | int | 1 | Min 1 |
| limit | int | 20 | Min 1, Max 50 |
| type | string | - | remittance or qr_payment |
| status | string | - | processing, completed, or failed |
Success Response (200):
{
"data": [
{
"id": "tx_rem_1",
"type": "remittance",
"status": "completed",
"amount": -2000,
"currency": "NOK",
"recipientName": "Mama Jasmina",
"createdAt": "..."
}
],
"pagination": { "page": 1, "limit": 20, "total": 3 }
}
Note: amount is negated in the response (always shown as outgoing).
GET /api/transactions/[id]
Get single transaction details with exchange rate info.
| Field | Source |
|---|---|
| File | app/api/transactions/[id]/route.ts |
| Auth | Required |
Success Response (200):
{
"data": {
"id": "tx_rem_1",
"type": "remittance",
"status": "completed",
"sendAmount": 2000,
"sendCurrency": "NOK",
"receiveAmount": 23400,
"receiveCurrency": "RSD",
"exchangeRate": 11.7,
"fee": 10,
"total": 2010,
"recipientName": "Mama Jasmina",
"recipientCountry": "Serbia",
"createdAt": "...",
"completedAt": "..."
}
}
GET /api/transactions/summary
Get transaction summary statistics (all-time and this month).
| Field | Source |
|---|---|
| File | app/api/transactions/summary/route.ts |
| Auth | Required |
Success Response (200):
{
"data": {
"allTime": {
"totalCount": 3,
"totalSent": 5000,
"totalPaid": 129,
"remittanceCount": 2,
"qrPaymentCount": 1
},
"thisMonth": { "..." }
}
}
POST /api/transactions/remittance
Create a remittance (international money transfer).
| Field | Source |
|---|---|
| File | app/api/transactions/remittance/route.ts |
| Auth | Required |
| Rate Limit | 10 req/min per IP |
| KYC | Must be approved |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| recipientId | string | Yes | Must belong to user |
| amount | number | Yes | 100-50,000 NOK, max 2 decimal places |
| currency | string | No | Defaults to NOK |
| bankAccountId | string | No | Defaults to primary bank account |
Business Logic:
- Verify recipient belongs to user
- Look up exchange rate for recipient's currency
- Verify bank account exists and has sufficient balance
- Fee: 0.5% of amount
- Debit bank account (atomic transaction)
- Create transaction record with status
processing
Success Response (201):
{
"data": {
"id": "tx_rem_...",
"type": "remittance",
"status": "processing",
"sendAmount": 2000,
"sendCurrency": "NOK",
"receiveAmount": 23400,
"receiveCurrency": "RSD",
"exchangeRate": 11.7,
"fee": 10,
"feePercent": 0.5,
"total": 2010,
"recipientName": "...",
"recipientCountry": "Serbia",
"fromAccount": "DNB",
"eta": "1-2 business days",
"createdAt": "..."
}
}
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | bad_request | Missing/invalid fields |
| 400 | no_bank_account | No linked bank account |
| 402 | insufficient_balance | Bank account balance too low |
| 403 | kyc_required | KYC not approved |
| 404 | not_found | Recipient not found |
| 422 | validation_error | Unsupported currency corridor |
POST /api/transactions/qr-payment
Create a QR payment to a merchant.
| Field | Source |
|---|---|
| File | app/api/transactions/qr-payment/route.ts |
| Auth | Required |
| Rate Limit | 10 req/min per IP |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| merchantId | string | Yes | Must exist |
| amount | number | Yes | 1-100,000 NOK, max 2 decimal places |
Business Logic:
- Verify merchant exists
- Get user's primary bank account
- Fee: 1% of amount
- Debit bank account (atomic transaction)
- Create transaction with status
completed(instant)
Success Response (201):
{
"data": {
"id": "tx_qr_...",
"type": "qr_payment",
"status": "completed",
"amount": 129,
"currency": "NOK",
"fee": 1.29,
"feePercent": 1,
"merchantName": "Ahmetov Kebab",
"merchantId": "mer_1",
"fromAccount": "DNB",
"createdAt": "..."
}
}
Recipients
GET /api/recipients
List user's recipients with pagination.
| Field | Source |
|---|---|
| File | app/api/recipients/route.ts |
| Auth | Required |
Query Parameters: page (default 1), limit (default 20, max 50)
Bank account numbers are masked in response (e.g., *****5678).
Supported Countries: RS (Serbia), BA (Bosnia), PL (Poland), PK (Pakistan), TR (Turkey)
POST /api/recipients
Add a new recipient.
| Field | Source |
|---|---|
| File | app/api/recipients/route.ts |
| Auth | Required |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| name | string | Yes | validateName() |
| country | string | Yes | Must be in supported list |
| currency | string | Yes | - |
| bankAccount | string | Yes | - |
| bankName | string | No | Sanitized to 200 chars |
DELETE /api/recipients/[id]
Delete a recipient.
| Field | Source |
|---|---|
| File | app/api/recipients/[id]/route.ts |
| Auth | Required |
Returns 204 No Content on success. Returns 404 if recipient not found or not owned by user.
Cards (FUTURE — feature-flagged, all flags default to false)
Note: The entire Cards section is a FUTURE feature, gated behind feature flags. All card-related feature flags default to
false. These endpoints exist in code but return 404 when flags are disabled. Cards require a card issuing partner (e.g., Stripe Issuing) before activation.
GET /api/cards
List user's cards (excludes cancelled).
| Field | Source |
|---|---|
| File | app/api/cards/route.ts |
| Auth | Required |
POST /api/cards
Create a new card (virtual or physical).
| Field | Source |
|---|---|
| File | app/api/cards/route.ts |
| Auth | Required |
Request Body:
| Field | Type | Required | Notes |
|---|---|---|---|
| type | string | No | virtual (default) or physical |
GET /api/cards/[id]
Get card details. Card number is masked (---- ---- ---- XXXX), CVV is hidden (---).
| Field | Source |
|---|---|
| File | app/api/cards/[id]/route.ts |
| Auth | Required |
PCI-DSS compliant: never exposes full card number or CVV.
PATCH /api/cards/[id]
Freeze or unfreeze a card.
| Field | Source |
|---|---|
| File | app/api/cards/[id]/route.ts |
| Auth | Required |
Request Body: { "status": "active" | "frozen" }
DELETE /api/cards/[id]
Cancel a card (soft delete — sets status to cancelled).
| Field | Source |
|---|---|
| File | app/api/cards/[id]/route.ts |
| Auth | Required |
POST /api/cards/[id]/physical
Order physical version of a virtual card.
| Field | Source |
|---|---|
| File | app/api/cards/[id]/physical/route.ts |
| Auth | Required |
| Feature Flag | physicalCards (returns 404 if disabled) |
Request Body: { "address": "..." } (min 10 chars)
POST /api/cards/[id]/pin
Set PIN for a card.
| Field | Source |
|---|---|
| File | app/api/cards/[id]/pin/route.ts |
| Auth | Required |
| Feature Flag | cardPin (returns 404 if disabled) |
Request Body: { "pin": "1234" } (exactly 4 digits)
PIN is hashed with bcrypt before storage.
GET /api/cards/[id]/limits
Get spending limits for a card.
| Field | Source |
|---|---|
| File | app/api/cards/[id]/limits/route.ts |
| Auth | Required |
| Feature Flag | spendingLimits (returns 404 if disabled) |
PUT /api/cards/[id]/limits
Set a spending limit for a card.
| Field | Source |
|---|---|
| File | app/api/cards/[id]/limits/route.ts |
| Auth | Required |
| Feature Flag | spendingLimits (returns 404 if disabled) |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| limitType | string | Yes | daily, weekly, monthly, or transaction |
| amount | number | Yes | Must be positive |
Replaces any existing limit of the same type.
Exchange Rates
See also: Currency Rates for provider source, cache/freshness behavior, fallback, and circuit-breaker verification.
GET /api/rates
Get all persisted exchange rates from NOK. This endpoint reads exchange_rates; it does not call the external FX providers directly.
| Field | Source |
|---|---|
| File | apps/drop-api/src/routes/rates.ts |
| Auth | None |
| Rate Limit | 120 req/min per IP |
Success Response (200):
{
"data": {
"baseCurrency": "NOK",
"rates": { "RSD": 10.17, "BAM": 0.17, "PLN": 0.374, "PKR": 26.5, "TRY": 3.39, "EUR": 0.087 },
"updatedAt": "...",
"stale": false
}
}
GET /api/rates/:currency
Get one persisted NOK → target-currency rate.
| Field | Source |
|---|---|
| File | apps/drop-api/src/routes/rates.ts |
| Auth | None |
| Rate Limit | 120 req/min per IP |
Response includes fee: 0.005 (0.5% remittance fee) and stale freshness status.
Notifications
GET /api/notifications
List all notifications for user.
| Field | Source |
|---|---|
| File | app/api/notifications/route.ts |
| Auth | Required |
| Feature Flag | notifications (default: enabled) |
PATCH /api/notifications
Mark notifications as read.
| Field | Source |
|---|---|
| File | app/api/notifications/route.ts |
| Auth | Required |
| Feature Flag | notifications |
Request Body: { "notificationIds": ["noti_..."] }
- Max 100 IDs per request
- IDs validated against format
^[a-z]+_[a-f0-9]{16}$
Settings
GET /api/settings
Get user settings (creates defaults if none exist).
| Field | Source |
|---|---|
| File | app/api/settings/route.ts |
| Auth | Required |
Defaults: currency=NOK, language=nb, pushEnabled=true, emailEnabled=true
PATCH /api/settings
Update user settings.
| Field | Source |
|---|---|
| File | app/api/settings/route.ts |
| Auth | Required |
Request Body (all optional):
| Field | Type | Validation |
|---|---|---|
| currency | string | Whitelist: EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR |
| language | string | Whitelist: nb, en, bs, sq |
| pushEnabled | boolean | - |
| emailEnabled | boolean | - |
Merchants
POST /api/merchants/register
Register as a merchant.
| Field | Source |
|---|---|
| File | app/api/merchants/register/route.ts |
| Auth | Required |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| businessName | string | Yes | validateName() |
| orgNumber | string | Yes | Exactly 9 digits, unique |
| address | string | No | Sanitized to 300 chars |
| bankAccount | string | Yes | Payout account |
Upgrades user role to merchant. Returns a QR code URI (drop://pay/{merchantId}).
GET /api/merchants/dashboard
Get merchant dashboard stats.
| Field | Source |
|---|---|
| File | app/api/merchants/dashboard/route.ts |
| Auth | Required (merchant role) |
Query Parameters: period — today (default), week, month
Returns: revenue, transactionCount, fees, netRevenue, nextPayout, payoutTime.
GET /api/merchants/qr
Get merchant QR code data.
| Field | Source |
|---|---|
| File | app/api/merchants/qr/route.ts |
| Auth | Required (merchant role) |
Returns: merchantId, businessName, qrValue (drop://pay/{id}), address.
GET /api/merchants/transactions
List merchant's QR payment transactions with pagination.
| Field | Source |
|---|---|
| File | app/api/merchants/transactions/route.ts |
| Auth | Required (merchant role) |
Query Parameters: page, limit
Customer names are partially anonymized (first name + last initial).
GDPR & Compliance
GET /api/user/data-export
Export all user data (GDPR right to data portability).
| Field | Source |
|---|---|
| File | app/api/user/data-export/route.ts |
| Auth | Required |
Creates a data_access_request record with type export and status completed.
Success Response (200):
{
"data": {
"user": {
"id": "usr_...",
"email": "...",
"first_name": "...",
"last_name": "...",
"phone": "+47...",
"date_of_birth": "1995-03-15",
"kyc_status": "approved",
"role": "user",
"created_at": "..."
},
"transactions": [ {...}, {...} ],
"recipients": [ {...}, {...} ],
"bankAccounts": [ {...} ],
"settings": { "currency": "NOK", "language": "nb", ... },
"consents": [ {...}, {...} ]
},
"exportedAt": "2026-02-17T..."
}
DELETE /api/user/account
Request account deletion (GDPR right to erasure).
| Field | Source |
|---|---|
| File | app/api/user/account/route.ts |
| Auth | Required |
Behavior:
- Soft-deletes user (sets
deleted_attimestamp) - Revokes all active sessions
- Creates
data_access_requestwith typeerasureand statuscompleted - Important: Data retained for 5 years per AML/KYC legal requirements (hvitvaskingsloven)
Success Response (200):
{
"message": "Account scheduled for deletion",
"retentionNote": "Data retained for 5 years per AML requirements"
}
GET /api/consents
List user's GDPR consents.
| Field | Source |
|---|---|
| File | app/api/consents/route.ts |
| Auth | Required |
Success Response (200):
{
"data": [
{
"id": "con_...",
"user_id": "usr_...",
"consent_type": "terms",
"granted": 1,
"granted_at": "2026-02-17T...",
"withdrawn_at": null,
"ip_address": "192.0.2.1"
}
]
}
POST /api/consents
Grant or withdraw a consent.
| Field | Source |
|---|---|
| File | app/api/consents/route.ts |
| Auth | Required |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| consentType | string | Yes | Must be one of: terms, privacy, marketing, cookies_analytics, cookies_marketing |
| granted | boolean | Yes | true to grant, false to withdraw |
Behavior:
- If consent exists: updates
grantedfield and sets eithergranted_atorwithdrawn_at - If consent doesn't exist: creates new consent record
- Records user's IP address with consent action
Success Response (200 for update, 201 for new):
{
"data": {
"id": "con_...",
"consent_type": "marketing",
"granted": 1,
"granted_at": "2026-02-17T...",
"withdrawn_at": null,
"ip_address": "192.0.2.1"
}
}
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | bad_request | Invalid consent type or missing fields |
GET /api/complaints
List user's complaints.
| Field | Source |
|---|---|
| File | app/api/complaints/route.ts |
| Auth | Required |
Query Parameters:
| Param | Type | Default | Notes |
|---|---|---|---|
| page | int | 1 | Pagination page number |
| limit | int | 10 | Items per page, max 100 |
Success Response (200):
{
"data": [
{
"id": "cmp_...",
"category": "transaction",
"subject": "Transaction delayed",
"description": "My remittance to Serbia is delayed...",
"status": "received",
"resolution": null,
"created_at": "2026-02-17T...",
"resolved_at": null
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 3,
"totalPages": 1
}
}
POST /api/complaints
Submit a complaint (Finansavtaleloven §3-53 compliance).
| Field | Source |
|---|---|
| File | app/api/complaints/route.ts |
| Auth | Required |
Request Body:
| Field | Type | Required | Validation |
|---|---|---|---|
| category | string | Yes | Must be one of: transaction, service, fees, privacy, technical, other |
| subject | string | Yes | Max 200 chars, sanitized |
| description | string | Yes | Max 2000 chars, sanitized |
Success Response (201):
{
"data": {
"id": "cmp_...",
"category": "fees",
"subject": "High transfer fee",
"description": "...",
"status": "received",
"created_at": "2026-02-17T..."
},
"commitmentNote": "We will review and respond to your complaint within 15 business days per Finansavtaleloven §3-53"
}
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | bad_request | Invalid category or empty fields |
POST /api/transactions/disclosure
Get full transaction fee and exchange rate disclosure before initiating payment.
| Field | Source |
|---|---|
| File | app/api/transactions/disclosure/route.ts |
| Auth | Required |
Request Body:
| Field | Type | Required | Notes |
|---|---|---|---|
| type | string | Yes | remittance or qr_payment |
| amount | number | Yes | Must be positive |
| currency | string | No | Defaults to NOK |
| recipientId | string | Conditional | Required for remittance |
Success Response (200):
{
"amount": 2000,
"fee": 10,
"feePercentage": 0.5,
"exchangeRate": 10.17,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"estimatedDelivery": "1-2 business days",
"totalCost": 2010
}
Fee Calculation:
- Remittance: 0.5% of amount
- QR payment: 1.0% of amount
Delivery Time:
- QR payment: "Instant"
- Remittance (EEA): "1-2 business days"
- Remittance (non-EEA): "2-4 business days"
GET /api/transactions/[id]/receipt
Get transaction receipt with full details.
| Field | Source |
|---|---|
| File | app/api/transactions/[id]/receipt/route.ts |
| Auth | Required |
Success Response (200):
{
"data": {
"transactionId": "tx_rem_1",
"date": "2026-02-17T...",
"type": "remittance",
"amount": 2000,
"currency": "NOK",
"fee": 10,
"exchangeRate": 10.17,
"receiveAmount": 20340,
"receiveCurrency": "RSD",
"recipient": {
"name": "Mama Jasmina",
"country": "RS"
},
"reference": "tx_rem_1",
"status": "completed",
"estimatedCompletion": null,
"completedAt": "2026-02-17T..."
}
}
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 404 | not_found | Transaction not found or not owned by user |
Health Check
GET /api/health
System health check (no auth required).
| Field | Source |
|---|---|
| File | app/api/health/route.ts |
| Auth | None |
Success Response (200):
{
"status": "ok",
"version": "0.1.0",
"uptime": 3600,
"db": "connected",
"dbLatencyMs": 1,
"timestamp": "..."
}
Returns 503 with status: "error" if database is unreachable.
Authentication
Drop Authentication System
Sources:
src/drop-app/src/app/api/auth/bankid/,src/drop-api/src/lib/bankid.ts,src/drop-api/src/routes/auth.ts
Overview
Drop uses BankID OIDC as the sole authentication method. Email/password login 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 BankID + Vipps)
Authentication Flow
BankID Login (Web)
Browser Next.js BFF BankID OIDC
| | |
| GET /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 |
User Creation
BankID login automatically creates user accounts:
- Parse pid from BankID ID token (Norwegian national ID, 11 digits)
- Hash pid with SHA-256 for storage (
national_id_hashcolumn) - Check existing user by
national_id_hash - If new: Create user with:
kyc_status = 'approved'(BankID = verified identity)kyc_method = 'bankid'auth_provider = 'bankid'password_hash = 'EIDONLY'(sentinel — no password auth)
- Age check: Must be >= 18 (parsed from pid birthdate)
JWT Structure
Payload
interface JwtPayload {
userId: string; // e.g., "usr_a1b2c3d4e5f6g7h8"
email: string; // e.g., "usr_xxx@bankid.drop.local"
role: string; // "user" or "merchant"
}
Claims
| Claim | Value |
|---|---|
exp |
Current time + 24h (web) / 7d (mobile) |
iat |
Current time |
iss |
drop-api (Hono) / none (Next.js) |
aud |
drop (Hono) / none (Next.js) |
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
| Endpoint | Limit |
|---|---|
| BankID initiate | 10/min per IP |
| BankID callback | 10/min per IP |
| Auth me/logout/refresh | No additional limit (auth required) |
Authorization
Role-Based Access
Two roles: user and merchant.
| Route | Auth | Role |
|---|---|---|
| GET /auth/bankid/initiate | None | - |
| POST /auth/bankid/callback | None | - |
| GET /auth/me | Required | Any |
| POST /auth/logout | Required | Any |
| POST /auth/refresh | Required | Any |
| POST /merchants/register | Required | Any (upgrades to merchant) |
| GET /merchants/dashboard | Required | Merchant |
Deprecated Endpoints
These endpoints return 410 Gone:
| Endpoint | Replacement |
|---|---|
POST /auth/login |
BankID OIDC flow |
POST /auth/register |
Automatic via BankID login |
POST /auth/verify-otp |
Not needed (BankID replaces OTP) |
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 signing secret (min 32 chars)
Optional
BANKID_AUTHORIZE_URL # Default: BankID prod authorize endpoint
BANKID_TOKEN_URL # Default: BankID prod token endpoint
BANKID_JWKS_URL # Default: BankID prod JWKS endpoint
BANKID_ISSUER # Default: BankID prod issuer
BANKID_MOCK=true # Dev mode: mock OIDC flow (no real BankID needed)
JWT_ALGORITHM # "HS256" (default) or "RS256"
JWT_EXPIRY # Default: "24h"
Merchant Flow
Merchants use the same BankID login as regular users. After logging in: