System Architecture
The eduID Wallet Matching Portal follows a microservices architecture with clear separation of concerns. Four services collaborate to authenticate users, match wallet-based identities to institutional accounts, and issue standardized OIDC tokens. Each service has a well-defined responsibility boundary, its own configuration surface, and can be scaled, deployed, and updated independently.
This page describes each service in detail, explains how they communicate, and discusses the technology decisions that shaped the architecture.
Architecture Diagram
The diagram above shows the four services and their primary communication paths. The browser only ever talks to the Portal. The Portal proxies requests to the STS and Auth Bridge. The STS delegates wallet authentication to the Auth Bridge and federates upstream to SURFconext. The Auth Bridge communicates with the database and KMS for persistence and cryptographic operations.
Service Descriptions
Portal (Next.js BFF) -- Port 3000
The Portal is the only service directly exposed to end users. It implements the Backend-for-Frontend (BFF) pattern, which means the browser never communicates directly with the STS or the Auth Bridge. Every API call from the frontend passes through server-side Next.js API routes that attach the appropriate authentication tokens, enforce access control, and proxy the request to the correct backend service. This eliminates an entire class of security concerns: tokens are never exposed to browser JavaScript, CORS complexity is avoided, and the attack surface is minimized to a single entry point.
The Portal is built with Next.js 15 and React 19, taking advantage of React Server Components for server-side rendering and reduced client-side JavaScript. NextAuth.js v5 manages the OAuth2 session lifecycle with the STS, using a JWT session strategy where session data is stored in encrypted HTTP-only cookies rather than in a server-side session store. This means the Portal itself is stateless and can be horizontally scaled without session affinity.
The Portal provides several key user-facing capabilities:
- Login page with two clear options: "Login with institution account" (federated path) and "Login with wallet" (wallet path). The interface is designed to be self-explanatory, requiring no prior knowledge of the underlying protocols.
- QR code display for wallet authentication. When the user selects wallet login, the Portal creates an OID4VP session via the Auth Bridge and renders the resulting QR code. The QR code encodes a request URI that the wallet app fetches to obtain the presentation request.
- Status polling that monitors the OID4VP session state. The Portal polls the Auth Bridge at regular intervals to detect when the wallet has submitted a Verifiable Presentation, when reconciliation is required, or when authentication is complete.
- IDV reconciliation UI that guides first-time wallet users through the one-time institutional login needed to link their wallet to their eduid. This interface explains why the step is necessary, initiates the reconciliation flow, and handles the callback when the user returns from SURFconext.
All sensitive configuration -- client secrets, STS endpoints, Auth Bridge URLs -- lives in server-side environment variables and is never bundled into the client-side JavaScript payload.
Service-STS (Security Token Service) -- Port 8080
The STS is a full-featured OAuth2 and OIDC authorization server. It is the single point of token issuance for the entire portal ecosystem. No matter how a user authenticates -- through SURFconext federation or through their wallet -- the STS is the service that mints the final access token and ID token that downstream systems consume.
The STS exposes the standard OIDC endpoints that any compliant relying party expects:
| Endpoint | Purpose |
|---|---|
/.well-known/openid-configuration | Discovery document with all endpoint URLs and supported capabilities |
/authorize | Authorization endpoint; initiates the OIDC code flow |
/token | Token endpoint; exchanges authorization codes for tokens |
/introspect | Token introspection for resource servers |
/revoke | Token revocation |
/userinfo | Claims endpoint returning user attributes for a valid access token |
/.well-known/jwks.json | JSON Web Key Set for token signature verification |
The STS supports two authentication paths, determined by the parameters on the /authorize request:
Upstream Federation (SURF/Keycloak OIDC RP): When the Portal initiates a standard login, the STS acts as an OIDC Relying Party to the configured upstream provider (SURFconext, or a Keycloak instance in development). It generates its own PKCE challenge, state, and nonce, redirects the user to the upstream provider, and processes the callback. Once the upstream tokens are received, the STS extracts claims from the ID token and userinfo endpoint, applies canonical attribute mapping rules, and issues its own tokens with the mapped claims.
Wallet Login (Delegates to Auth Bridge): When the Portal initiates a wallet login with a login_hint parameter of the form oid4vp:{sessionId}, the STS recognizes this as a wallet authentication request. Instead of redirecting to an upstream provider, it calls the Auth Bridge to retrieve the resolved identity for the given session. The Auth Bridge returns the canonical claims that were either loaded from a cached identity link binding (fast path) or established through reconciliation (new holder path). The STS then issues tokens with those claims, just as it would for a federated login.
PKCE (Proof Key for Code Exchange) is required on all authorization flows. The STS does not support the implicit flow or any flow without PKCE. This is a deliberate security decision that prevents authorization code interception attacks.
The STS also supports RFC 8693 token exchange, enabling scenarios where a service needs to exchange one token for another with different scopes or audiences. Claim mapping is entirely configuration-driven: a set of attribute rules defines how upstream claims are projected into the canonical token schema, including default values, required claims, and merge strategies for when claims come from multiple sources.
Service-Auth-Bridge -- Port 8090
The Auth Bridge is the heart of the portal's identity matching and reconciliation capabilities. While the STS handles token minting and OIDC protocol compliance, the Auth Bridge handles the harder problem: figuring out who a wallet holder is, matching them to an institutional identity, and caching the result for future use.
The Auth Bridge's responsibilities span several domains:
OID4VP Session Management. The Auth Bridge creates and manages OID4VP (OpenID for Verifiable Presentations) sessions. When a user initiates wallet login, the Auth Bridge generates a session containing a request URI that the wallet will fetch. The request object -- a signed JWT -- specifies which credentials the wallet should present, using DCQL (Digital Credentials Query Language) to express the query. The Auth Bridge tracks the session through its lifecycle: created, interaction started (wallet fetched the request), verifying (VP received), verified (VP valid), and onwards to either completion or reconciliation.
Verifiable Presentation Verification. When a wallet submits a Verifiable Presentation, the Auth Bridge runs it through the Universal OID4VP Verifier provided by the IDK. This verifier validates the VP's cryptographic signatures, checks credential status (revocation), verifies the holder binding (proving the presenter controls the private key), and extracts the credential attributes and the holder's public key.
HMAC-Based Identity Matching. After extracting the holder's public key from a verified VP, the Auth Bridge computes an HMAC-SHA256 hash of the key's fingerprint using Key A (the holder key hashing key). This hash becomes the identifier_hash used for database lookups. If a matching identity_match record exists, the holder is known and the fast path proceeds. If not, the holder is new and reconciliation begins. A separate key, Key B, is used for hashing institutional identifiers (like eduid), ensuring that the compromise of one key cannot be used to reverse hashes created with the other.
Encrypted Identity Link Bindings. When a holder is matched, the Auth Bridge retrieves the associated identity_link_binding record, which contains the cached canonical attributes encrypted with Key C. These attributes -- eduid, name, email, assurance metadata -- are what the STS needs to issue a token. The encryption uses AES-256-GCM with a unique initialization vector per record, ensuring that identical attributes produce different ciphertext.
Reconciliation Orchestration. When a holder key is unknown, the Auth Bridge evaluates a set of reconciliation selector rules to determine which reconciliation plan to execute. The default plan, RunIdv, initiates an Identity Verification flow by redirecting the user to SURFconext for institutional authentication. The Auth Bridge acts as an OIDC Relying Party during this flow, receiving the callback, extracting the institutional claims, and creating the identity match and link binding records that enable future fast-path authentication. The reconciliation framework is extensible: new plans can be added through configuration to support alternative identity verification methods.
External REST API. The Auth Bridge exposes a REST API that allows third-party systems to interact with the identity matching infrastructure. This API supports session creation, status queries, and identity resolution, enabling integration scenarios beyond the Portal's own UI.
PostgreSQL Persistence. All data persistence is handled through SqlDelight, which generates type-safe Kotlin data access code from SQL definitions. The Auth Bridge never constructs raw SQL strings. Schema changes are managed through SqlDelight migrations, and the generated code ensures compile-time type safety for all database operations.
PostgreSQL
PostgreSQL serves as the shared persistence layer for the Auth Bridge. It stores all identity matching data, session state, reconciliation records, and encrypted auxiliary data across seven tables. The database is designed with several non-negotiable constraints:
All sensitive data is encrypted at rest. Cached identity attributes are stored as AES-256-GCM encrypted envelopes. The database never contains plaintext names, email addresses, or institutional identifiers. Even if an attacker gains full read access to the database, they obtain only ciphertext that is useless without the corresponding KMS-managed encryption keys.
All identifier lookups use HMAC-SHA256 hashes. Rather than indexing on plaintext identifiers, the database indexes on HMAC hashes. A query like "find the identity match for this holder key" translates to a lookup on HMAC-SHA256(holder_key_fingerprint, Key_A). This means the database can perform efficient equality lookups without ever storing or indexing the original identifier.
Multi-tenancy is built in from the ground up. Every table includes a tenant_id column, and every query filters by tenant. This enables a single deployment to serve multiple institutions with complete data isolation at the application level.
SqlDelight generates all data access code. The SQL definitions in .sq files are the single source of truth for the database schema. SqlDelight compiles these into type-safe Kotlin interfaces and data classes. This eliminates entire categories of bugs: typos in column names, type mismatches between Kotlin and SQL, and forgotten WHERE clauses are all caught at compile time rather than at runtime.
Inter-Service Communication
All inter-service communication within the portal uses HTTP. There is no message queue, no event bus, and no shared state beyond the database. This keeps the architecture simple, debuggable, and easy to reason about.
| From | To | Path | Purpose |
|---|---|---|---|
| Browser | Portal | HTTPS | User interaction, session cookies |
| Portal | STS | HTTP /authorize, /token | OAuth2 authorization code flow |
| Portal | Auth Bridge | HTTP /auth/oid4vp/* | Wallet session management (create, poll, IDV) |
| STS | Auth Bridge | HTTP /auth/oid4vp/sessions/{id}/complete | Retrieve resolved identity for wallet auth |
| STS | SURF/Keycloak | HTTPS OIDC | Federation upstream (authorize, token, userinfo) |
| Auth Bridge | SURF/Keycloak | HTTPS OIDC | IDV reconciliation (authorize, token, userinfo) |
| Auth Bridge | PostgreSQL | JDBC | Data persistence |
| Auth Bridge | KMS | API | Cryptographic operations (HMAC, encrypt, decrypt) |
| Wallet App | Auth Bridge | HTTPS | VP submission via request_uri |
A few things are worth noting about this communication topology:
- The browser never talks to the STS or Auth Bridge directly. All communication is proxied through the Portal's server-side routes. This is the BFF pattern in action.
- The STS and Auth Bridge communicate only for wallet authentication. During federated login, the Auth Bridge is not involved at all. This clean separation means federated login continues to work even if the Auth Bridge is temporarily unavailable.
- The wallet app communicates directly with the Auth Bridge for VP submission. The QR code the user scans contains a
request_uripointing to the Auth Bridge. The wallet fetches the request object and submits the VP directly. This is necessary because the wallet is an external application that cannot use the Portal's session cookies. - KMS calls are made only by the Auth Bridge. The STS does not directly interact with the KMS. It relies on the Auth Bridge to perform all cryptographic operations related to identity matching and return the decrypted results.
Technology Decisions
Why Kotlin Multiplatform
The IDK libraries that underpin the portal are built with Kotlin Multiplatform, enabling shared code across JVM, JavaScript, and native targets. The portal's backend services (STS and Auth Bridge) run on the JVM, but the same cryptographic and protocol code could be compiled for other platforms if needed. Within the JVM services, dependency injection is handled at compile time using kotlin-inject, which generates DI code during compilation rather than resolving dependencies at runtime through reflection. This eliminates an entire class of runtime errors (missing bindings, circular dependencies) by catching them at build time.
Why Next.js
Next.js was chosen for the Portal because it natively supports the Backend-for-Frontend pattern through server-side API routes and React Server Components. Server Components execute on the server and never ship their code to the browser, which means sensitive logic -- token handling, API proxying, session management -- stays on the server by default. There is no risk of accidentally exposing a token in client-side JavaScript because the token-handling code physically cannot run in the browser. Additionally, Next.js 15's App Router provides a clean way to organize the Portal's routes and middleware, and its built-in support for environment variables keeps configuration management straightforward.
Why PostgreSQL
Identity data demands ACID guarantees. When the Auth Bridge creates an identity match and a corresponding link binding during reconciliation, those two records must be created atomically. If one succeeds and the other fails, the system is in an inconsistent state: a holder key might be matched but have no cached attributes, or attributes might exist with no matching lookup hash. PostgreSQL's transaction support ensures that reconciliation operations are all-or-nothing. Its mature indexing capabilities also support the HMAC hash lookups that are central to the matching engine's performance.
Why Separate STS from Auth Bridge
It would have been simpler to build a single service that handles both token minting and identity matching. The decision to separate them was driven by several factors:
Different responsibilities. The STS is an OAuth2/OIDC authorization server. Its job is to implement the OIDC specification correctly, manage client registrations, issue tokens, and handle federation. The Auth Bridge is an identity matching engine. Its job is to manage OID4VP sessions, verify credentials, perform HMAC lookups, and orchestrate reconciliation. These are fundamentally different domains with different complexity profiles.
Different lifecycle. Changes to reconciliation logic (new providers, different selector rules, updated credential schemas) should not require redeploying the token minting infrastructure. Conversely, updates to the OIDC configuration (new clients, claim mapping changes) should not affect the identity matching engine. Separate services mean separate deployment pipelines and separate release schedules.
Different scaling needs. The STS handles every authentication request -- both federated and wallet-based. The Auth Bridge handles only wallet-based requests. In a deployment where 80% of authentication is federated, the STS needs more capacity than the Auth Bridge. Separate services enable independent scaling.
Different trust boundaries. The STS holds the signing keys for tokens and manages client credentials. The Auth Bridge holds the KMS access credentials for HMAC and encryption keys. Neither service needs the other's secrets. Separation enforces the principle of least privilege at the service level.