BankID OIDC Integration
BankID OIDC Integration
Version: 1.0 Date: 2026-02-21 Author: Banking Architecture Team Status: Approved Applies to: Drop — Authentication via Norwegian BankID
1. Overview
Drop uses BankID OIDC (OpenID Connect) as its sole authentication method. BankID provides Strong Customer Authentication (SCA) out of the box — combining possession (mobile device / code generator) with knowledge (personal code) or inherence (biometrics).
Email/password login has been removed. All deprecated auth endpoints return 410 Gone.
| Property | Value |
|---|---|
| Identity Provider | BankID (prod: auth.bankid.no) |
| Protocol | OpenID Connect 1.0 (Authorization Code Flow) |
| Auth endpoint | https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/auth |
| Token endpoint | https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/token |
| JWKS endpoint | https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/certs |
| Issuer | https://auth.bankid.no/auth/realms/prod |
| Scopes | openid profile |
| Response type | code (authorization code flow) |
| Source code | src/drop-api/src/lib/bankid.ts |
2. BankID OIDC Flow
2.1 Web Flow (Next.js BFF)
sequenceDiagram
participant B as Browser
participant N as Next.js BFF<br/>/api/auth/bankid/*
participant BID as BankID OIDC<br/>(auth.bankid.no)
B->>N: GET /api/auth/bankid
Note over N: Rate limit check (10/min per IP)
N->>N: Generate state (crypto.randomUUID)<br/>Generate nonce (crypto.randomUUID)
N->>N: Set httpOnly cookie: bankid_state={state}
N-->>B: {redirectUrl: "https://auth.bankid.no/auth/...?<br/>client_id=X&redirect_uri=Y&response_type=code<br/>&scope=openid+profile&state=Z&nonce=N"}
B->>BID: Browser redirects to BankID authorize URL
Note over B,BID: User authenticates with BankID<br/>(BankID app / code generator / biometrics)
BID-->>B: 302 redirect to /api/auth/bankid/callback<br/>?code=AUTH_CODE&state=Z
B->>N: GET /api/auth/bankid/callback?code=AUTH_CODE&state=Z
N->>N: Verify state matches bankid_state cookie
N->>BID: POST /token<br/>{grant_type: authorization_code,<br/>code: AUTH_CODE, redirect_uri: Y,<br/>client_id: X, client_secret: S}
BID-->>N: {id_token: "eyJ...", access_token: "...",<br/>token_type: "Bearer", expires_in: 300}
N->>N: Verify id_token signature via JWKS
N->>N: Validate: issuer, audience, expiry, nonce
N->>N: Extract pid (fødselsnummer) from claims
N->>N: Parse birthdate from pid, verify age >= 18
N->>N: findOrCreateUser(pid, name)
N->>N: Create session (sessions table)
N->>N: Issue Drop JWT, set httpOnly cookie (drop_token)
N-->>B: 302 redirect to /dashboard
2.2 Mobile Flow (Hono API + Expo)
sequenceDiagram
participant M as Mobile App<br/>(Expo)
participant H as Hono API<br/>/v1/auth/bankid/*
participant BID as BankID OIDC
M->>H: GET /v1/auth/bankid/initiate?platform=mobile
Note over H: Rate limit check (10/min per IP)
H->>H: Generate state + nonce
H-->>M: {redirectUrl: "https://auth.bankid.no/...",<br/>state: "Z"}
M->>M: Store state in memory
M->>BID: Open BankID in secure browser<br/>(expo-web-browser)
Note over M,BID: User authenticates with BankID
BID-->>M: Deep link: drop://auth/callback<br/>?code=AUTH_CODE&state=Z
M->>M: Verify state matches stored state
M->>H: POST /v1/auth/bankid/callback<br/>{code: AUTH_CODE, state: Z, platform: "mobile"}
H->>BID: POST /token (exchange code)
BID-->>H: {id_token, access_token}
H->>H: Verify id_token via JWKS
H->>H: Extract pid, verify age >= 18
H->>H: findOrCreateUser(pid, name)
H->>H: Create session
H->>H: Issue Drop JWT (7-day expiry)
H-->>M: {token: "eyJ...", data: {id, name, role}}
M->>M: Store token in SecureStore/AsyncStorage
3. Token Lifecycle
3.1 Drop JWT (Issued After BankID Auth)
stateDiagram-v2
[*] --> Issued: BankID auth success<br/>→ createSession() + signJWT()
Issued --> Active: Token in use<br/>(cookie or Bearer header)
Active --> Active: API request<br/>→ verifySession() passes
Active --> Expired: 24h (web) / 7d (mobile)<br/>expiry reached
Active --> Revoked: User logs out<br/>→ revokeAllSessions()
Active --> Revoked: Session marked revoked<br/>(admin action)
Expired --> Refreshed: POST /auth/refresh<br/>→ new JWT + new session
Expired --> [*]: User must re-authenticate
Revoked --> [*]: User must re-authenticate
Refreshed --> Active
3.2 JWT Payload Structure
interface DropJwtPayload {
userId: string; // "usr_a1b2c3d4e5f6g7h8"
email: string; // "[email protected]" (placeholder)
role: string; // "user" | "merchant"
iat: number; // Issued at (Unix timestamp)
exp: number; // Expiry (iat + 24h web / 7d mobile)
iss?: string; // "drop-api" (Hono only)
aud?: string; // "drop" (Hono only)
}
3.3 Token Lifetimes
| Platform | Token Location | Lifetime | Refresh |
|---|---|---|---|
| Web | httpOnly cookie (drop_token) |
24 hours | POST /api/auth/refresh |
| Mobile | Bearer header (SecureStore) | 7 days | POST /v1/auth/refresh |
3.4 Session Tracking
Every JWT has a corresponding record in the sessions table:
| Column | Purpose |
|---|---|
id |
Session ID (ses_<hex16>) |
user_id |
FK to users.id |
token_hash |
SHA-256 hash of the JWT string |
expires_at |
Token expiry timestamp |
revoked |
0 = active, 1 = revoked |
On every authenticated request, the middleware:
- Extracts JWT from cookie (web) or Authorization header (mobile)
- Verifies JWT signature and expiry with
jose.jwtVerify() - Hashes the JWT with SHA-256
- Looks up the session by
token_hash - Verifies
revoked = 0andexpires_at > now - Rejects the request if any check fails
4. BankID Claims Mapping
4.1 ID Token Claims
| BankID Claim | Type | Drop Usage | Stored In |
|---|---|---|---|
sub |
string | Subject identifier (BankID internal) | Fallback for pid |
pid |
string | Norwegian fødselsnummer (11 digits) | Hashed as users.national_id_hash (SHA-256) |
name |
string | Full name (e.g., "Ola Nordmann") | Split into users.first_name + users.last_name |
birthdate |
string | ISO date (not always present) | Parsed from pid instead (more reliable) |
iss |
string | Issuer URL | Validated against expected issuer |
aud |
string | Client ID | Validated against BANKID_CLIENT_ID |
exp |
number | Token expiry | Validated (must be in future) |
iat |
number | Issued at | Validated (must be reasonable) |
nonce |
string | Anti-replay | Matched against value sent in auth request |
4.2 PID (Fødselsnummer) Processing
The pid (personal identification number) is an 11-digit Norwegian national ID that encodes the birthdate:
Format: DDMMYYNNNCC
DD = Day of birth (01-31)
MM = Month of birth (01-12)
YY = Year of birth (2 digits)
NNN = Individual number (000-999)
CC = Check digits (Luhn variant)
Century derivation (from individual number):
NNN 000-499 → born 1900-1999
NNN 500-999 → born 2000-2099
Source: bankid.ts:37-51 (parseBirthdateFromPid)
Processing pipeline:
- Parse birthdate from pid digits → ISO date string (
YYYY-MM-DD) - Verify age >= 18 by comparing birthdate to current date (
isOver18()) - Hash pid with SHA-256 for storage:
crypto.createHash("sha256").update(pid).digest("hex") - Never store raw pid — only the hash exists in the database (
users.national_id_hash)
4.3 User Deduplication
Users are identified by national_id_hash (SHA-256 of their fødselsnummer). This ensures:
- The same person logging in on different devices gets the same account
- Re-authentication after token expiry reconnects to the existing account
- Future Vipps Login integration can match users by the same pid hash
Source: bankid.ts:208-249 (findOrCreateUser)
5. User Creation (Auto-Registration)
BankID login automatically creates user accounts. There is no separate registration step.
| Field | Value | Source |
|---|---|---|
id |
usr_<hex16> |
randomId("usr") |
email |
[email protected] |
Placeholder (BankID doesn't provide email) |
password_hash |
EIDONLY |
Sentinel — no password authentication |
auth_provider |
bankid |
Indicates BankID-only auth |
first_name |
First word of name claim |
Split from BankID name |
last_name |
Remaining words of name claim |
Split from BankID name |
date_of_birth |
Parsed from pid | parseBirthdateFromPid(pid) |
kyc_status |
approved |
BankID = verified identity |
kyc_method |
bankid |
KYC via BankID |
kyc_verified_at |
Current timestamp | Set on creation |
national_id_hash |
SHA-256(pid) | For user deduplication |
role |
user |
Default; upgradable to merchant |
6. SCA Compliance
6.1 PSD2 SCA Requirements Met by BankID
| PSD2 RTS Requirement | BankID Implementation | Status |
|---|---|---|
| Two of three factors (knowledge, possession, inherence) | BankID app (possession) + personal code (knowledge) or biometrics (inherence) | Met |
| Independence of factors | Separate device (BankID app) + separate knowledge factor | Met |
| Dynamic linking (for PISP) | Amount and payee displayed in BankID app during payment approval | Met (ASPSP-side) |
| Authentication code specific to amount + payee | BankID generates unique code per transaction | Met (ASPSP-side) |
| Session timeout | BankID sessions expire (configurable by ASPSP) | Met |
| Max 5 failed attempts | BankID locks after failed attempts | Met (BankID-side) |
6.2 Drop's SCA Scope
Drop performs SCA at two levels:
- Drop authentication (login): BankID OIDC — satisfies SCA for account access
- Payment SCA (PISP): Delegated to ASPSP — user re-authenticates with BankID at the bank for each payment (see open-banking-aisp-pisp.md)
7. Error Handling
7.1 Error Matrix
| Error Scenario | HTTP Status | Error Code | User Message (Norwegian) | Recovery Action |
|---|---|---|---|---|
| BankID auth cancelled by user | 400 | bankid_cancelled |
"Du avbrøt BankID-innlogging." | Retry login |
| BankID auth timeout | 408 | bankid_timeout |
"BankID-sesjonen utløp. Prøv igjen." | Retry login |
| State mismatch (CSRF) | 403 | state_mismatch |
"Sikkerhetssjekk feilet. Prøv igjen." | Restart flow |
| Token exchange failed | 502 | token_exchange_failed |
"Kunne ikke koble til BankID. Prøv igjen." | Retry later |
| JWKS verification failed | 502 | jwks_verification_failed |
"Teknisk feil. Prøv igjen senere." | Alert ops, retry |
| Invalid pid format | 422 | invalid_pid |
"Ugyldig identifikasjon fra BankID." | Contact support |
| User under 18 | 403 | underage |
"Du må være minst 18 år for å bruke Drop." | No recovery — legal requirement |
BANKID_CLIENT_ID missing |
500 | config_error |
"Teknisk feil. Prøv igjen senere." | Fix server config |
| Session revoked | 401 | session_revoked |
"Sesjonen din er utløpt. Logg inn på nytt." | Re-authenticate |
| JWT expired | 401 | token_expired |
"Sesjonen din er utløpt. Logg inn på nytt." | Refresh or re-authenticate |
| Rate limited | 429 | rate_limited |
"For mange forsøk. Vent litt og prøv igjen." | Wait and retry |
7.2 Error Flow
flowchart TD
A[BankID auth attempt] --> B{Auth successful?}
B -->|Yes| C[Exchange code for tokens]
B -->|No: cancelled| D[Return bankid_cancelled]
B -->|No: timeout| E[Return bankid_timeout]
C --> F{Token exchange OK?}
F -->|Yes| G[Verify ID token via JWKS]
F -->|No| H[Return token_exchange_failed<br/>Log error, alert ops]
G --> I{JWKS valid?}
I -->|Yes| J[Extract pid from claims]
I -->|No| K[Return jwks_verification_failed<br/>Check JWKS URL, cert rotation]
J --> L{Valid pid format?}
L -->|Yes| M{Age >= 18?}
L -->|No| N[Return invalid_pid]
M -->|Yes| O[findOrCreateUser]
M -->|No| P[Return underage<br/>403 Forbidden]
O --> Q[Create session + JWT]
Q --> R[Redirect to /dashboard]
8. Mock Mode (Development)
When BANKID_MOCK=true, the OIDC flow is simulated locally without contacting BankID:
| Mock Code Prefix | Mock User | Birthdate | Age Check |
|---|---|---|---|
underage* |
"Ung Testbruker" (pid: 01011012345) |
2010-01-01 | Fails (under 18) |
| (anything else) | "Test Bankersen" (pid: 01019012345) |
1990-01-01 | Passes |
Source: bankid.ts:188-195 (getMockUserInfo)
Mock flow:
initiateOIDC()generates a redirect URL (same structure, but not called)- Frontend skips BankID redirect, posts a mock code to callback
exchangeAndVerify()detectsisMock=trueand returns mock user infofindOrCreateUser()runs normally (creates real DB records)
9. Production Migration Checklist
| Step | Description | Status |
|---|---|---|
| 1 | Register BankID OIDC client at developer.bankid.no |
Not started |
| 2 | Obtain BANKID_CLIENT_ID and BANKID_CLIENT_SECRET |
Not started |
| 3 | Configure callback URLs (web + mobile deep link) | Not started |
| 4 | Set BANKID_MOCK=false (or remove env var) |
Pending |
| 5 | Test OIDC flow in BankID preprod environment | Not started |
| 6 | Configure JWKS caching (jose handles this, verify TTL) | Pending |
| 7 | Set secure JWT_SECRET (min 32 chars, from vault) |
Pending |
| 8 | Verify nonce validation in callback | Implemented in code |
| 9 | Test underage rejection with real BankID test user | Not started |
| 10 | Test session revocation and re-authentication | Implemented in code |
| 11 | Move to production BankID endpoints | Not started |
| 12 | Monitor JWKS key rotation (BankID rotates keys) | Pending |
10. Phase 2: Vipps Login (Planned)
Vipps Login uses the same OIDC protocol. Integration plan:
- Register Vipps Login client at
portal.vipps.no - Add Vipps OIDC endpoints alongside BankID
- Vipps also returns Norwegian pid (fødselsnummer)
- User deduplication via
national_id_hash— same hash regardless of whether user logs in with BankID or Vipps - UI: Login screen offers two buttons: "Logg inn med BankID" and "Logg inn med Vipps"
Optional: Use an OIDC aggregator like Idura to get a single integration point for BankID + Vipps + future providers.
11. Environment Variables
Required (Production)
| Variable | Description | Example |
|---|---|---|
BANKID_CLIENT_ID |
OIDC client ID from BankID | abc123-def456 |
BANKID_CLIENT_SECRET |
OIDC client secret | secret-from-bankid |
BANKID_CALLBACK_URL |
Web callback URL | https://getdrop.no/api/auth/bankid/callback |
BANKID_CALLBACK_URL_MOBILE |
Mobile deep link callback | drop://auth/callback |
JWT_SECRET |
Drop JWT signing secret (min 32 chars) | (from Vaultwarden) |
Optional
| Variable | Description | Default |
|---|---|---|
BANKID_AUTHORIZE_URL |
Override authorize endpoint | BankID prod URL |
BANKID_TOKEN_URL |
Override token endpoint | BankID prod URL |
BANKID_JWKS_URL |
Override JWKS endpoint | BankID prod URL |
BANKID_ISSUER |
Override expected issuer | BankID prod issuer |
BANKID_MOCK |
Enable mock mode (true/false) |
false |
JWT_ALGORITHM |
JWT signing algorithm | HS256 |
JWT_EXPIRY |
Token lifetime | 24h |
12. Cross-References
- Authentication System: ../../backend/AUTHENTICATION.md — Full auth system documentation
- Open Banking AISP/PISP: open-banking-aisp-pisp.md — ASPSP SCA (separate from BankID login SCA)
- Security Architecture: ../hld/security-architecture.md — JWT security, session management
- Login Flow (LLD): ../lld/flow-login-authentication.md — Step-by-step login UX
- Database Schema: ../../backend/DATABASE-SCHEMA.md —
users,sessionstables - Source:
src/drop-api/src/lib/bankid.ts— BankID OIDC implementation