Issue and verify credentials using the Swiss Digital identity public beta, ASP.NET Core and .NET Aspire

Switzerland is running a public beta of its national digital identity infrastructure, known as swiyu. It implements the OpenID for Verifiable Credential Issuance and OpenID for Verifiable Presentations specifications, so developers can build real issuer and verifier flows against it today, well before the final production rollout. This post walks through a reference implementation that issues and verifies verifiable credentials using ASP.NET Core, orchestrated with .NET Aspire, based on Damien Bod’s write-up on his blog. The full source is on GitHub at swiss-ssi-group/swiyu-aspire-aspnetcore, and a live demo runs on Azure Container Apps.

Understanding the swiyu building blocks

swiyu is not a single service you call from your code. It is a set of generic containers provided by the Swiss government that already implement the specification level plumbing for issuance and verification. You still need a Postgres database, a digital wallet installed on the end user’s phone, two public containers for issuance and verification that talk to the wallet, and two private management containers plus your own ASP.NET Core application that adds the business specific logic on top.

In a production setup you would typically split the issuer and the verifier into two separate ASP.NET Core solutions. That keeps their permissions, secrets and scaling profiles independent, which matters because the issuer holds the ability to mint new credentials and needs much tighter access control than the verifier.

High level view of the issuer and verifier components around the swiyu generic containers
High level view of the issuer and verifier components around the swiyu generic containers

Why the development setup needs a public endpoint

The digital wallet app on the phone needs to reach the issuing and verifying containers over the internet, even during local development. You can work around this with ngrok, or, as Damien did here, deploy the two public containers to real endpoints and point your local configuration at them. Either way, treat the management APIs with more caution than the public issuance and verification APIs.

At the time of writing, the generic container APIs do not support OAuth, so network level isolation is the only real protection available for the management endpoints. Keep them inside a private network and do not expose the management endpoints publicly, no matter how tempting it is during a demo.

Issuing a credential

Setting up an issuer means registering through the swiyu onboarding process and defining a credential type in a metadata configuration file. Damien created a custom damienbod-vc credential type this way, alongside the metadata that swiyu’s generic issuer container reads to know which fields the credential carries.

Once the credential type exists, issuing an actual credential is a POST request to the issuer management API. Here is the method that builds the request and sends it:

public async Task<string> IssuerCredentialAsync(PayloadCredentialData payloadCredentialData)
{
    _logger.LogInformation("Issuer credential for data");
 
    var statusRegistryUrl = 
      "https://status-reg.trust-infra.swiyu-int.admin.ch/api/v1/statuslist/8cddcd3c-d0c3-49db-a62f-83a5299214d4.jwt";
    var vcType = "damienbod-vc";
 
    var json = GetBody(statusRegistryUrl, vcType, payloadCredentialData);
 
    var jsonContent = new StringContent(json, Encoding.UTF8, "application/json");
 
    using HttpResponseMessage response = await _httpClient.PostAsync(
        $"{_swiyuIssuerMgmtUrl}/api/v1/credentials", jsonContent);
 
    if(response.IsSuccessStatusCode)
    {
        var jsonResponse = await response.Content.ReadAsStringAsync();
        return jsonResponse;
    }
 
    var error = await response.Content.ReadAsStringAsync();
    _logger.LogError("Could not create issue credential {issuer}", error);
    throw new Exception(error);
}

This method builds the JSON payload with GetBody, posts it to /api/v1/credentials on the issuer management URL, and returns the raw response if the call succeeds. If the request fails, it logs the error and throws. That is deliberate, nobody should get a half issued credential response back in a UI without knowing something went wrong. Notice the status registry URL points at a statuslist JWT, which is how swiyu implements revocation. A verifier checks this list at presentation time to see whether the credential has since been revoked, so do not skip wiring that up in your own credential type.

The payload itself comes from a simple template method:

private static string GetBody(string statusRegistryUrl, 
	string vcType, 
	PayloadCredentialData payloadCredentialData)
{
    var json = $$"""
         {
           "metadata_credential_supported_id": [
             "{{vcType}}"
           ],
           "credential_subject_data": {
             "firstName": "{{payloadCredentialData.FirstName}}",
             "lastName": "{{payloadCredentialData.LastName}}",
             "birthDate": "{{payloadCredentialData.BirthDate}}"
           },
           "offer_validity_seconds": 86400,
           "credential_valid_until": "2030-01-01T19:23:24Z",
           "credential_valid_from": "2025-01-01T18:23:24Z",
           "status_lists": [
             "{{statusRegistryUrl}}"
           ]
         }
         """;
    return json;
}

credential_subject_data holds the actual claims, first name, last name and birth date in this example. metadata_credential_supported_id must exactly match a supported credential type from your issuer configuration, and credential_valid_from and credential_valid_until define the validity window baked into the credential itself, independent of the status list check. Get the metadata_credential_supported_id value wrong and the container rejects the call with a fairly generic error, so double check it against the config file first when something refuses to issue.

Building the issuer UI and generating a QR code

The end user needs a way to trigger issuance and add the credential to their wallet, so this is wired into a Razor page. The OnPostAsync handler calls the issuer service, decodes the response, and turns the offer_deeplink field into a QR code using Net.Codecrete.QrCodeGenerator and ImageMagick:

public async Task OnPostAsync()
{
    var vci = await _issuerService.IssuerCredentialAsync(
        new PayloadCredentialData
        {
            FirstName = "damienbod",
            LastName = "cool apps",
            BirthDate = DateTime.UtcNow.ToShortDateString()
        });
 
    var data = JsonSerializer.Deserialize<CredentialIssuerModel>(vci);
 
    var qrCode = QrCode.EncodeText(data!.offer_deeplink, QrCode.Ecc.Quartile);
    QrCodePng = qrCode.ToPng(20, 4, MagickColors.Black, MagickColors.White);
 
    QrCodeUrl = data!.offer_deeplink;
    ManagementId = data!.management_id;
}

Scanning this QR code with the swiyu wallet app on a phone makes the wallet fetch the offer and prompt the user to accept the credential. FirstName, LastName and BirthDate are hardcoded here for demo purposes. In anything beyond a proof of concept, this data has to come from an authenticated session, because whoever controls these values controls what gets attested in the credential.

The issuer UI showing a generated QR code for the wallet to scan
The issuer UI showing a generated QR code for the wallet to scan

Polling for the issuance status

After the QR code appears, the page needs to know when the wallet has actually accepted the offer. JavaScript on the client polls a status endpoint, which in turn calls the issuer management API:

public async Task<StatusModel?> GetIssuanceStatus(string id)
{
    using HttpResponseMessage response = await _httpClient.GetAsync(
        $"{_swiyuIssuerMgmtUrl}/api/v1/credentials/{id}/status");
 
    if (response.IsSuccessStatusCode)
    {
        var jsonResponse = await response.Content.ReadAsStringAsync();
 
        if(jsonResponse == null)
        {
            _logger.LogError("GetIssuanceStatus no data returned from Swiyu");
            return new StatusModel { id="none", status="ERROR"};
        }
 
        return JsonSerializer.Deserialize<StatusModel>(jsonResponse);
    }
 
    var error = await response.Content.ReadAsStringAsync();
    _logger.LogError("Could not create issue credential {issuer}", error);
    throw new Exception(error);
}

A null response here does not necessarily mean a network failure. It usually means the container returned an unexpected payload, which is why the code logs and falls back to an explicit ERROR status rather than letting a null propagate up to the UI. Watch this method in production, polling loops like this are an easy place to leak requests if the frontend does not back off once a terminal state is reached.

Verifying a credential

Verification mirrors issuance closely, since both sides implement OpenID for Verifiable Presentations. The VerificationService class wraps the verifier management API. Here is the constructor and the entry point for verifying the custom damienbod-vc credential:

public class VerificationService
{
    private readonly ILogger<VerificationService> _logger;
    private readonly string? _swiyuVerifierMgmtUrl;
    private readonly string? _issuerId;
    private readonly HttpClient _httpClient;
 
    public VerificationService(IHttpClientFactory httpClientFactory,
        ILoggerFactory loggerFactory, IConfiguration configuration)
    {
        _swiyuVerifierMgmtUrl = configuration["SwiyuVerifierMgmtUrl"];
        _issuerId = configuration["ISSUER_ID"];
        _httpClient = httpClientFactory.CreateClient();
        _logger = loggerFactory.CreateLogger<VerificationService>();
    }
 
    public async Task<string> CreateDamienbodVerificationPresentationAsync()
    {
        _logger.LogInformation("Creating verification presentation");
 
        var inputDescriptorsId = Guid.NewGuid().ToString();
        var presentationDefinitionId = "00000000-0000-0000-0000-000000000000";
 
        var json = GetDataForLocalCredential(inputDescriptorsId,
           presentationDefinitionId, _issuerId!, "damienbod-vc");
 
        return await SendCreateVerificationPostRequest(json);
    }

CreateDamienbodVerificationPresentationAsync builds a presentation_definition describing which claims the verifier wants to see and which issuer DID it will accept, then posts that to the verifier management API. The _issuerId configuration value matters here. Point it at the wrong DID and the verifier rejects presentations from your own issuer, which is a confusing failure the first time you hit it.

The presentation definition itself is a fairly verbose piece of JSON:

private string GetDataForLocalCredential(string inputDescriptorsId, 
       string presentationDefinitionId, 
       string issuer, 
       string vcType)
{
    var json = $$"""
         {
             "accepted_issuer_dids": [ "{{issuer}}" ],
             "jwt_secured_authorization_request": true,
             "presentation_definition": {
                 "id": "{{presentationDefinitionId}}",
                 "name": "Verification",
                 "purpose": "Verify damienbod VC",
                 "input_descriptors": [
                     {
                         "id": "{{inputDescriptorsId}}",
                         "format": {
                             "vc+sd-jwt": {
                                 "sd-jwt_alg_values": [ "ES256" ],
                                 "kb-jwt_alg_values": [ "ES256" ]
                             }
                         },
                         "constraints": {
                             "fields": [
                                 {
                                     "path": [ "$.vct" ],
                                     "filter": {
                                         "type": "string",
                                         "const": "{{vcType}}"
                                     }
                                 },
                                 { "path": [ "$.firstName" ] },
                                 { "path": [ "$.lastName" ] },
                                 { "path": [ "$.birthDate" ] }
                             ]
                         }
                     }
                 ]
             }
         }
         """;
    return json;
}

The important part is the constraints.fields array. Each entry is a JSON path into the credential together with an optional filter. The first field pins $.vct to the expected credential type string, so a verifier configured for damienbod-vc will not accept a Beta ID credential even if a user tries to present one. The remaining fields request specific claims, firstName, lastName and birthDate, to be disclosed. Since swiyu credentials are SD-JWT based, only the fields listed here get revealed to the verifier. Everything else in the credential stays undisclosed, which is the whole point of selective disclosure.

Building the verifier UI

The verifier side follows the same QR code pattern as the issuer. OnPostAsync calls CreateDamienbodVerificationPresentationAsync, decodes the verification_url from the response and renders it as a QR code for the wallet to scan:

public async Task OnPostAsync()
{
    var presentation = await _verificationService
        .CreateDamienbodVerificationPresentationAsync();
 
    var verificationResponse = JsonSerializer
        .Deserialize<CreateVerificationPresentationModel>(presentation);
 
    QrCodeUrl = verificationResponse!.verification_url;
 
    var qrCode = QrCode.EncodeText(verificationResponse!.verification_url, QrCode.Ecc.Quartile);
    QrCodePng = qrCode.ToPng(20, 4, MagickColors.Black, MagickColors.White);
 
    VerificationId = verificationResponse.id;
}

Scanning this QR code with the wallet app starts the presentation flow. The wallet shows the user exactly which fields are being requested and asks for consent before disclosing anything, which is a meaningful difference from a typical OAuth consent screen. Here the user is approving disclosure of specific data points rather than just granting a scope.

The verifier UI showing a generated QR code for the wallet to scan
The verifier UI showing a generated QR code for the wallet to scan

Verifying credentials issued by someone else

One detail worth calling out: a verifier is not limited to checking credentials your own issuer minted. Any credential issued anywhere on the swiyu public beta infrastructure can be verified, as long as you know the issuer’s DID and the credential type, and the input_descriptors and path values are configured to match what that issuer put into the credential. This is what makes the model interoperable. A relying party does not need a direct integration with every possible issuer, only knowledge of the credential schema it is willing to accept.

What is still missing before this goes to production

Damien is upfront that this is a work in progress, and the open issues list is worth reading before anyone treats it as production ready. The generic container APIs do not support OAuth today and ship with fairly weak default security headers, so network isolation is doing all the work of protecting the management endpoints. There is no automated infrastructure deployment in the sample, which is worth fixing with Terraform or an equivalent before running this anywhere real.

An API gateway in front of the container APIs would help harden the endpoints further, and the public facing issuance and verification endpoints have no DDoS protection built in, something like Cloudflare is a sensible addition. Deep link handling in the UI is also not implemented yet, so scanning the QR code is the only supported flow right now, there is no same device fallback for a user browsing on their phone.

A few practical notes

If you are evaluating this for an Azure native equivalent, the natural comparison is Microsoft Entra Verified ID, which implements the same underlying OpenID4VCI and OpenID4VP standards but wraps them in Entra’s own management plane and Azure AD authentication rather than these community provided generic containers. The trade-off is flexibility against convenience. swiyu’s generic containers let you plug in any credential schema and any subject data, but you own the container lifecycle, the Postgres database and the security hardening yourself. Entra Verified ID takes more of that operational burden away but ties you more tightly to the Microsoft identity stack.

One more thing worth remembering with SD-JWT based credentials: because disclosure is selective, get the constraints.fields list right early. Adding a new field to a credential type later usually means issuing an entirely new credential to every existing holder, wallets do not retroactively pick up schema changes on a credential that has already been issued.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading