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 (last verified August 2026, swiyu-verifier 4.2.0 / OID4VP 1.0).
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
Beta-IDs get revoked. They are Public-Beta test credentials, not documents with a long life. A Beta-ID that worked weeks ago can show up as revoked without any notice in the wallet — the wallet still presents it, and the verifier is the one that rejects it (
credential_revoked, see Troubleshooting). If verification starts failing at the very last step, issue a fresh Beta-ID here and delete the old one from 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 4.x!
The image version is deliberately pinned to
4.2.0. Two moving targets make the pin necessary:
- Up to 2.x only the old
presentation_definitionformat was understood; DCQL requests were rejected with"PresentationDefinition must be provided". Since v3.0.0 only DCQL is supported.- 3.x still emitted a draft-era authorization request with
"client_id_scheme": "did"and an unprefixedclient_id. The swiyu wallet meanwhile enforces OID4VP 1.0, whereclient_id_schemeno longer exists and the scheme is a prefix of theclient_id(decentralized_identifier:did:webvh:…). Scanning the QR code against a 3.x verifier fails immediately withinvalid_request. Fixed in v4.0.0 via theclient_id_prefixproperty (defaultdecentralized_identifier).
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.jwt |
Do not change: the swiyu wallet only accepts direct_post.jwt and dc_api.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 4.2.0 (see above). -
Wallet aborts right when scanning with
invalid_request→ the authorization request is not OID4VP 1.0 compliant. Three causes, all fixed in this repo:ResponseModeis plaindirect_post. The wallet'sResponseModeenum (RequestObject.swiftinswiyu-admin-ch/eidch-ios-wallet) only has cases fordirect_post.jwtanddc_api.jwt, and it decodes the field non-optionally — sodirect_postmakes the whole request object fail to decode before any of its contents are looked at. Usedirect_post.jwt(encrypted response); the verifier then addsjwksandencrypted_response_enc_values_supportedto the client metadata by itself, and decrypts the response before the web app sees it. Check this cause first: it is our own setting, so it survives verifier upgrades and looks identical to the two below.- A 3.x image is running, whose request still uses
client_id_schemeinstead of a prefixedclient_id. Upgrade to 4.x (see above). - The client metadata still uses the draft-era
vp_formatskey, or declares a format other than the one being requested. OID4VP 1.0 renamed the key tovp_formats_supported, and for the Beta-ID it must declaredc+sd-jwt(withsd-jwt_alg_values/kb-jwt_alg_values) — notjwt_vp. Check what is actually published:https://<domain>/oid4vp/api/openid-client-metadata.json.
After any of these:
docker compose up -dand click "New request" — an already generated QR code keeps the old request object. Note thatdocker compose up -ddoes not notice changes to an inlineconfigs:block; for metadata changes usedocker compose up -d --force-recreate <verifier-service>, otherwise the old metadata is served on silently. -
credential_revoked/ "Credential is not valid" → the presented Beta-ID is revoked in the status registry. This is a correct rejection, not a bug: everything up to and including signature and holder-binding checks succeeded, and onlySdJwtVpTokenVerifier.verifyStatusfailed. Issue a fresh Beta-ID (step 1) and delete the old one from the wallet. -
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.
The wallet shows one generic message (invalid_request) for a long list of causes and
sends nothing back when it rejects a request object, so guessing is expensive. These
three steps localise the fault quickly, in this order.
1. Ask the verifier what it recorded. The management API holds the wallet's own error code — this is what a "revoked", "expired" or "not accepted" case looks like from the server side. It is not published through the reverse proxy, so query it from inside the docker network:
docker run --rm --network <project>_internal curlimages/curl -s http://<verifier-service>:8080/management/api/verifications/<id>
# → {"state":"FAILED","wallet_response":{"error_code":"credential_revoked", …}}Nothing recorded at all means the wallet never sent a response — the request object was rejected locally, and step 2 applies.
2. Find out whether the wallet even fetched the request object. Enable the access
log on the reverse-proxy site block temporarily (in Caddy: add log inside the site
block, then caddy reload) and scan once. The wallet identifies itself as
swiyuWallet:
GET 200 /oid4vp/api/request-object/<id> UA: swiyuWallet
A hit followed by silence means the request object was fetched and discarded during validation — the fault is in its content. No hit at all means the deep link, DNS or TLS is the problem, and the request object is irrelevant.
3. Read the wallet's own rules. Both wallets are open source and the validation is
short and readable — it is faster than inferring wallet behaviour from verifier release
notes, which is how the direct_post cause above stayed hidden through two upgrades:
REPO=swiyu-admin-ch/eidch-ios-wallet
SRC=Modules/Features/BITOpenID/Sources/BITOpenID/Domain
gh api "repos/$REPO/contents/$SRC/Validators/RequestObjectValidator.swift" --jq .content | base64 -dRequestObjectValidator.swift lists every rejection condition, and
Domain/Models/Presentation/RequestObject.swift defines which fields are decoded
non-optionally and which enum values are accepted — a value outside those enums fails
the whole decode before any content is inspected. The Android wallet lives in
eidch-android-wallet; on
Android, adb logcat | grep -i swiyu prints the rejection reason directly.
Useful self-checks that need no wallet at all:
# request object: header, claims and Content-Type (must be application/oauth-authz-req+jwt)
curl -sD - https://<domain>/oid4vp/api/request-object/<id>
# published client metadata
curl -s https://<domain>/oid4vp/api/openid-client-metadata.json | jq| 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