A C#/.NET 10 ASP.NET Core web app (Minimal API with a browser UI) that verifies the Swiss E-ID (swiyu Public Beta) Beta-ID credential via the swiyu-verifier management REST API (OpenID4VP / DCQL) — the Swiss sibling of miEUDIverifier.
The web page shows a QR code. The user scans it with the swiyu app (iOS/Android), confirms the data sharing of their Beta-ID, and the page displays the verified identity data: given name, family name, date of birth, over-18, sex, nationality, place of birth and portrait photo.
Status: tested end-to-end with the real swiyu app + Beta-ID (July 2026).
A public test instance is available at https://miswiyuverifier.mitterbucher.com/ — open it, scan the QR code with your swiyu app and confirm.
No guarantee of availability — this endpoint may be offline at any time. To run your own instance, see the Quick start below.
- You need the swiyu app with a Beta-ID (free, self-issued — see step 1 below).
- Every visitor gets their own verification session (own QR code); any number of verifications can run in parallel. "New request" starts a fresh verification anytime.
- Note: the Beta-ID contains made-up pseudo data only (not an official document).
┌─ host machine ───────────────────────────────┐
swiyu app ──HTTPS:443──► router │ Caddy (TLS, /oid4vp/* only) │
(smartphone) │ └─► swiyu-verifier :8083 ◄── PostgreSQL │
▲ │ ▲ management API (local only!) │
│ scans QR │ │ │
Browser ◄──────:5070──────────── │ miSWIYUverifier (ASP.NET) │
└──────────────────────────────────────────────┘
- The ASP.NET frontend (
src/miSWIYUverifier) creates a verification via the management API of the swiyu-verifier (DCQL query on the Beta-ID) and renders the returnedverification_deeplinkas a QR code. - The swiyu app fetches the signed request object from the publicly reachable HTTPS
EXTERNAL_URLand posts the presentation back to it. TLS is terminated by Caddy, which forwards only/oid4vp/*to the container — the management API stays unreachable from outside. - The frontend polls
GET /management/api/verifications/{id}untilstate = SUCCESSand displays the data fromwallet_response.credential_subject_data. - All cryptographic validation (SD-JWT, signatures, holder binding, revocation) is done by the swiyu-verifier service — the core library stays slim.
| Path | Content |
|---|---|
src/miSWIYUverifier.Core |
Reusable library: VerifierApiService, models, QrCodeService, DI extension |
src/miSWIYUverifier |
Minimal-API web host with single-page UI (port 5070) |
src/miSWIYUverifier.Core.Tests |
xUnit tests |
docker/ |
docker-compose for swiyu-verifier + PostgreSQL (local dev) and docker-compose.vps.yml (full stack for production); docker/.env (not in the repo!) holds DID + signing key |
Dockerfile |
Web-app image, built locally on the deployment host (never pushed to a registry) |
proxy/Caddyfile.example |
Reverse-proxy template (TLS on 443 → /oid4vp/* → localhost:8083); the real proxy/Caddyfile is not in the repo |
tools/ |
DID toolbox JAR (download see below, not in the repo) |
didlog.jsonl |
Backup of the uploaded DID log — created during onboarding, not in the repo |
.didtoolbox/ |
Private keys of the verifier DID (not in the repo — back them up externally!) |
No Docker image of this project is built or published because the deployment configuration contains real organisation data (registered verifier DID, partner registration, signing key) — even though it is "only" the swiyu Public Beta. If you reuse this project, run the onboarding (step 2) with your own DID and keys; all real values stay local in
docker/.env,proxy/Caddyfileand.didtoolbox/(excluded via.gitignore).
- .NET 10 SDK
- Docker Desktop
- Java 21+ (only for the DID toolbox during onboarding, e.g.
winget install EclipseAdoptium.Temurin.21.JRE) - swiyu app on your smartphone (App Store / Play Store: "swiyu")
- A Beta-ID in the swiyu app (step 1)
- A registered verifier DID on the swiyu identifier registry (step 2)
- A public HTTPS URL (step 3 — own domain or tunnel)
Five steps from zero to a running verifier — issue a Beta-ID (1), onboard your verifier DID (2), expose the OID4VP endpoint via HTTPS (3), start the swiyu-verifier container (4) and run the web app (5).
Anyone can issue themselves a free Beta-ID (pseudo identity for the Public Beta):
- Install and set up the swiyu app
- Open https://www.bcs.admin.ch/bcs-web
- Fill in the form (name, date of birth, … — freely chosen, not an official document)
- Scan the displayed QR code with the swiyu app → the Beta-ID is in the wallet
The verifier needs its own DID on the identifier registry of the Public Beta. Reference: cookbook "Onboarding base and trust registry"
Important: there is no "DID function" inside the ePortal itself — it is only the authenticated entry point. The DID is created locally with the DID toolbox.
- ePortal (https://eportal.admin.ch, AGOV or CH login): search for the service "swiyu Trust Infrastructure" and open it → leads to the swiyu portal.
- swiyu portal: register a business partner (name + contact e-mail).
Result: the
SWIYU_PARTNER_ID(visible in the dashboard). - API self-service portal (https://selfservice.api.admin.ch/api-selfservice):
subscribe to the
swiyucorebusiness_identifierAPI. Result:CUSTOMER_KEY,CUSTOMER_SECRET,BOOTSTRAP_REFRESH_TOKENand anACCESS_TOKEN(valid for ~24 h only — renew via the refresh token afterwards). The second API (swiyucorebusiness_status) is only needed by issuers.
# returns id (= IDENTIFIER_REGISTRY_ID) and identifierRegistryUrl
Invoke-RestMethod -Method Post `
-Uri "https://identifier-reg-api.trust-infra.swiyu-int.admin.ch/api/v1/identifier/business-entities/<SWIYU_PARTNER_ID>/identifier-entries" `
-Headers @{ Authorization = "Bearer <ACCESS_TOKEN>" }Download the DID toolbox (Maven Central — the latest GitHub release has no assets!):
Invoke-WebRequest "https://repo1.maven.org/maven2/ch/admin/swiyu/didtoolbox/2.2.1/didtoolbox-2.2.1-jar-with-dependencies.jar" -OutFile tools\didtoolbox.jar
java -jar tools\didtoolbox.jar create --identifier-registry-url "<identifierRegistryUrl from 2b>" | Out-File didlog.jsonl -Encoding utf8NoBOMThe toolbox creates three key pairs in .didtoolbox/ (incl. auth-key-01 =
EC P-256 key, PEM) and prints the DID log. The DID has the form
did:webvh:<scid>:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:<IDENTIFIER_REGISTRY_ID>
(method did:webvh, the successor of did:tdw).
Invoke-RestMethod -Method Put `
-Uri "https://identifier-reg-api.trust-infra.swiyu-int.admin.ch/api/v1/identifier/business-entities/<SWIYU_PARTNER_ID>/identifier-entries/<IDENTIFIER_REGISTRY_ID>" `
-Headers @{ Authorization = "Bearer <ACCESS_TOKEN>" } `
-ContentType "application/jsonl+json" -InFile didlog.jsonlSuccess check (public, no token): GET <identifierRegistryUrl>/did.jsonl → HTTP 200.
(Note: GET on the management entry itself returns 404 — that is normal.)
VERIFIER_DID— the DID from the DID log (state.id)DID_VERIFICATION_METHOD— DID + key fragment:<DID>#auth-key-01SIGNING_KEY— content of.didtoolbox/auth-key-01(PEM)
Back up
.didtoolbox/externally — it is the only place holding the private keys!
The swiyu app requires HTTPS with a publicly trusted certificate (Let's Encrypt is fine, self-signed is rejected).
Prerequisites: DNS A record pointing to your public IP, router port forwarding TCP 443 to this machine, certificate + key as PEM files.
-
Install Caddy:
winget install CaddyServer.Caddy -
Copy
proxy/Caddyfile.exampletoproxy/Caddyfileand replace the<PLACEHOLDERS>(hostname + certificate paths); the real file stays local via.gitignore. Peculiarities of this setup:auto_https disable_redirects— Caddy opens no port-80 listener (another service already ran there; port 80 is not forwarded anyway)- Only
handle /oid4vp/*is proxied, everything else → 404 (protects the management API!)
-
Run:
caddy run --config proxy\CaddyfileCaddy then runs only until logout/reboot — no autostart configured. Start it manually after each reboot (or register it as a scheduled task/service if needed).
-
Test from outside:
https://<domain>/oid4vp/api/openid-client-metadata.jsonmust return the verifier metadata;/management/...must return 404.
Certificate renewal: after renewal (Let's Encrypt, 90 days) replace the PEM files at the configured path and restart Caddy.
cloudflared tunnel --url http://localhost:8083 # or: ngrok http 8083Use the displayed URL as EXTERNAL_URL. Caution: the URL changes on every tunnel
start (→ adjust .env, restart the container, "New request"); additionally the
management API becomes public too — acceptable only for Public-Beta development.
cd docker
Copy-Item .env.example .env
# edit .env: EXTERNAL_URL, VERIFIER_DID, DID_VERIFICATION_METHOD, SIGNING_KEY
docker compose up -dThis yields the containers miSWIYUverifier-verifier and miSWIYUverifier-postgres,
both with restart: unless-stopped (they survive reboots as long as Docker Desktop
starts at sign-in: Settings → General → "Start Docker Desktop when you sign in").
Checks:
GET http://localhost:8083/actuator/health→UP- Swagger UI: http://localhost:8083/swagger-ui/index.html
GET http://localhost:8083/actuator/info→ must show version 3.x!
The image version is deliberately pinned to
3.0.3. Thelatesttag on ghcr.io still pointed to 2.1.2 (as of July 2026), which only knows the oldpresentation_definitionformat and rejects DCQL requests with"PresentationDefinition must be provided". Since v3.0.0 only DCQL is supported.
The management API (port 8083) must never be publicly reachable — it is unprotected by default and hands out identity data.
dotnet run --project src/miSWIYUverifierThe browser opens http://localhost:5070 with the QR code. Scan it with the swiyu app and confirm the data sharing — the page displays the verified data.
Port 5070, not 5060: Chrome blocks 5060 as an "unsafe port" (SIP port,
ERR_UNSAFE_PORT).
The live demo runs on a Linux VPS where a central Caddy container terminates TLS
for several projects. docker/docker-compose.vps.yml runs the full stack (web app
built from source + swiyu-verifier + PostgreSQL) without publishing any host
ports — the services that Caddy must reach join the external Caddy docker
network instead and are addressed by service name:
git clone https://github.com/Mibuw/miSWIYUverifier /opt/miswiyuverifier
# create docker/.env (EXTERNAL_URL=https://<your-domain>, DID, signing key)
cd /opt/miswiyuverifier/docker
docker compose -f docker-compose.vps.yml up -d --buildCaddy site block (TLS via Let's Encrypt is automatic once DNS points at the host):
<your-domain> {
handle /oid4vp/* { reverse_proxy miswiyu-verifier:8080 }
handle /api/debug* { respond 404 }
handle { reverse_proxy miswiyu-webapp:5070 }
}
The /api/debug* block at the proxy matters: behind a reverse proxy the app sees
the proxy's IP instead of the caller's, so the app's built-in localhost check is
complemented by blocking the route at the edge. The management API is never
published at all — it is only reachable inside the docker network.
src/miSWIYUverifier/appsettings.json, section VerifierSettings
(overridable via environment variables with the SWIYU_ prefix, e.g.
SWIYU_VerifierSettings__ManagementUrl):
| Setting | Default | Description |
|---|---|---|
ManagementUrl |
http://localhost:8083 |
Management API of the swiyu-verifier |
PollIntervalSeconds / PollTimeoutSeconds |
2 / 300 | Polling for the result |
VctValues |
["betaid-sdjwt"] |
Accepted credential types |
AcceptedIssuerDids |
Beta-ID issuer | Trusted issuers (mandatory!) |
RequestedClaims |
name, birth date, age_over_18, sex, nationality, birth_place, portrait | Requested Beta-ID attributes |
JwtSecuredAuthorizationRequest |
true |
Signed request object (JAR) |
ResponseMode |
direct_post |
or direct_post.jwt |
PurposeName / PurposeDescription / PurposeScope |
demo texts | Purpose shown in the wallet (empty = omit) |
Available Beta-ID claims: document_number, given_name, family_name,
birth_date, age_over_16, age_over_18, age_over_65, age_birth_year,
birth_place, place_of_origin, sex, nationality, portrait,
issuance_date, expiry_date, and more.
| Route | Description |
|---|---|
GET / |
Single-page UI (DE/EN via Accept-Language). The page creates its own session per visitor via the REST API and polls its status. |
GET /api/debug/{id} |
Session details incl. raw management-API response — reachable from localhost only |
For integration into your own applications; sessions expire after 30 minutes
(the verifier deletes verifications after VERIFICATION_TTL_SEC = 900 s anyway).
| Route | Description |
|---|---|
POST /api/verification |
Start a new verification → { id, deepLink, qrCodeUrl, qrBase64 } |
GET /api/verification/{id}/qrcode |
QR code as image/png (directly usable as <img src>) |
GET /api/verification/{id}/status |
{ id, status, error } — status: waiting | complete | partial | error |
GET /api/verification/{id}/data |
Verified identity data: 200 + { id, status, data } when done, 202 while waiting, 502 on error |
DELETE /api/verification/{id} |
Discard the session (204) |
Typical client flow:
# 1. start a verification
$v = Invoke-RestMethod -Method Post http://localhost:5070/api/verification
# 2. show the QR code (e.g. <img src="http://localhost:5070$($v.qrCodeUrl)">)
# 3. poll the status until != waiting
Invoke-RestMethod "http://localhost:5070/api/verification/$($v.id)/status"
# 4. fetch the data
Invoke-RestMethod "http://localhost:5070/api/verification/$($v.id)/data"
# → { "id": "...", "status": "complete",
# "data": { "givenName": "...", "familyName": "...", "birthDate": "...",
# "ageOver18": "true", "sex": "1", "nationality": "CH",
# "birthPlace": "...", "portrait": "data:image/jpeg;base64,...",
# "additionalClaims": { } } }The REST API is unauthenticated and intended for internal-network use — do not expose port 5070 publicly for production use, or put your own auth layer in front of it.
"PresentationDefinition must be provided"→ a 2.x image is running.docker compose pull && docker compose up -d; check the version via/actuator/info. The image in the compose file is pinned to 3.0.3 (see above).- Verification SUCCESS but no data shown → since v3,
credential_subject_datais grouped by DCQL credential id:{ "<credential-id>": [ { …claims… } ] }instead of flat. The extraction inVerifierApiService.ExtractIdentityDatadescends recursively and supports both shapes. For diagnosis:GET /api/debug/{id}→lastRawResponse. - Browser shows
ERR_UNSAFE_PORT→ Chrome blocks port 5060 (among others); that is why the app runs on 5070. Failed to create verification→ is the container running?docker ps, if neededdocker compose -f docker/docker-compose.yml up -d.- Wallet reports an error on scan → is
EXTERNAL_URLreachable from the phone? Is Caddy running (port 443)? Test from outside:https://<domain>/oid4vp/api/openid-client-metadata.json. After changingEXTERNAL_URL, restart the container and click "New request". issuer_not_accepted→ the Beta-ID issuer DID inAcceptedIssuerDidsdoes not match. Check the current value in the Beta-ID cookbook.- 404 while polling / QR expired → verifications expire after
VERIFICATION_TTL_SEC(900 s); the app's polling timeout is 300 s. Click "New request". - Caddy fails to start:
listening on :80 … access denied→ port 80 is in use elsewhere; that is whyauto_https disable_redirectsis set in the Caddyfile. - Not reachable after a Windows reboot → start Caddy manually (no autostart configured); Docker Desktop must start at sign-in.
- Access token expired (registry API) → only relevant for onboarding/DID
updates; renew via
BOOTSTRAP_REFRESH_TOKENor the API self-service portal. Normal operation needs no token.
| Component | Start | Survives reboot? |
|---|---|---|
| Caddy (443) | caddy run --config proxy\Caddyfile |
❌ start manually |
| Containers (8083, 5434) | docker compose up -d in docker/ |
✅ (unless-stopped, if Docker Desktop autostarts) |
| Web app (5070) | dotnet run --project src/miSWIYUverifier |
❌ start manually |
- swiyu-verifier: https://github.com/swiyu-admin-ch/swiyu-verifier
(
documentation/verification_process.md,openapi.yaml,sample.compose.yml) - Cookbooks: https://swiyu-admin-ch.github.io/
- Beta Credential Service (Beta-ID): https://www.bcs.admin.ch/bcs-web
- DID toolbox: https://github.com/swiyu-admin-ch/didtoolbox-java
- ePortal: https://eportal.admin.ch · API self-service: https://selfservice.api.admin.ch/api-selfservice
- Sibling project for the EU: miEUDIverifier
MIT — © 2026 Wolfgang Mitterbucher