INITIALIZING
Technical

Duende (Identity Server), Certificates and Postman. What could go wrong? Signed JWT Requests the "easy" way. Part 01

Duende (Identity Server), Certificates and Postman. What could go wrong? Signed JWT Requests the "easy" way. Part 01

Ever wondered if there's an “easy” way to test the signed-JWT-request flow? There is — if you're already handing OKTA or Auth0 a monthly invoice for the privilege. But what do you do when you're prototyping a POC at midnight, or your actual dev environment is still “provisioning” three sprints later (it happens constantly, don't pretend it doesn't)?

Enter Duende IdentityServer — formerly IdentityServer4, a name half of us still type by muscle memory like an old phone number.

This is a three-part series:

  1. Installing Duende locally, in Docker, on Azure, and behind NGINX.
  2. Using Postman to sign requests and obtain bearer tokens.
  3. Doing the same thing in C# code, for people who don't trust a GUI to keep a secret.

If you just want the “give me the working thing, I don't care how it's built” version, skip straight to the Docker and NGINX section below — no judgment, I'd probably do the same.

Duende Source Code

Clone the repo and stand up a bare-bones host. Fine for testing; for real development, use an actual data store instead of config files pretending to be a database.

git clone https://github.com/DuendeSoftware/IdentityServer.git

Open it in Visual Studio 2022 (free Community edition exists, no excuses), set Host.Main as the startup project, hit run, and congratulations — you now own a fully functioning Identity Server, quietly sitting on localhost, silently judging every token you throw at it.

Want it in Docker instead? Right-click the project, add Docker support, and Visual Studio will scaffold the Dockerfile for you. Hit run on the container target and it builds the image, starts it, and attaches the debugger automatically — one of the few times modern tooling has made my life measurably better instead of just different.

Custom Implementation

Now the actual fun part: making this thing ours instead of Duende's sample playground.

Add an appsettings.json (set to copy-always, unless you enjoy debugging “why is my config empty” at midnight):

{
  "IsContainerExternalConfiguration": false,
  "ContainerExternalConfiguration": "/appsettings/appsettings.external.duende.json",
  "IsUseReverseProxy": false,
  "ProxyBasePath": "/yourvirtualdirectory",
  "ApiScopes": [ { "Name": "api.readonly" } ],
  "ApiResources": [ { "Name": "CustomResource", "DisplayName": "Test Resource", "Scopes": [ "api.readonly" ] } ],
  "Clients": [
    {
      "ClientId": "myclient",
      "ClientSecrets": [ { "Type": "X509CertificateBase64", "Value": "MIIDA...V" } ],
      "AllowedGrantTypes": [ "client_credentials" ],
      "AllowedScopes": [ "api.readonly" ]
    }
  ],
  "Users": [
    {
      "SubjectId": "1",
      "Username": "myuser",
      "Password": "horseWalkingBeachMouse4573@#%",
      "Name": "Darth Seldon",
      "GivenName": "Darth",
      "FamilyName": "Seldon",
      "Email": "someemail@server.com",
      "EmailVerified": "true",
      "WebSite": "https://darthseldon.net"
    }
  ]
}

Yes, Darth Seldon is in there as the test user, complete with a password that reads like four random nouns had an argument. Some habits die hard, and honestly, horseWalkingBeachMouse4573@#% has better entropy than most “real” passwords I've audited professionally, so who's really winning here.

These settings decide whether the host runs standalone, containerized, or behind a reverse proxy, and configure one test client plus one test user using the signed-request flow.

Next, gut Duende's sample configuration classes (Clients.cs, ClientsConsole.cs, ClientsWeb.cs, Resources.cs) and replace them with your own trimmed-down versions — they're intentionally boring, just static classes returning empty or minimal lists so nothing conflicts with the config-driven clients above. Resources.cs is the one worth actually looking at, since it defines the identity claims you're exposing:

public static class Resources
{
    public static readonly IEnumerable<IdentityResource> IdentityResources =
        new[]
        {
            new IdentityResources.OpenId(),
            new IdentityResources.Profile(),
            new IdentityResources.Email(),
            new IdentityResource("custom.profile", new[] { JwtClaimTypes.Name, JwtClaimTypes.Email, "location", JwtClaimTypes.Address })
        };
}

Then update IdentityServerExtensions.cs so it actually reads from your appsettings instead of Duende's hardcoded demo data — the important bit is wiring AddInMemoryClients, AddInMemoryApiScopes, and friends to configuration.GetSection(...), plus setting sane key-rotation defaults:

options.KeyManagement.RotationInterval = TimeSpan.FromDays(30);
options.KeyManagement.PropagationTime = TimeSpan.FromDays(2);
options.KeyManagement.RetentionDuration = TimeSpan.FromDays(7);
options.KeyManagement.DeleteRetiredKeys = true;

And in Program.cs, the only genuinely interesting part is the reverse-proxy handling — reading IsUseReverseProxy and rewriting the request's PathBase so the app knows it's living under a subpath instead of at the root, like an app having a minor identity crisis and needing to be told where it actually lives:

if (builder.Configuration.GetSection("IsUseReverseProxy").Get<bool>())
{
    app.UseForwardedHeaders(new ForwardedHeadersOptions
    {
        ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
    });

    var path = new Uri(builder.Configuration.GetSection("ProxyBasePath").Get<string>()).AbsolutePath;
    if (path != "/")
    {
        app.Use((context, next) =>
        {
            context.Request.PathBase = new PathString(path);
            return next.Invoke();
        });
    }
}

Run it now, and everything works except one thing: that X509CertificateBase64 value is still "MIIDA...V" — three dots and a prayer, not an actual certificate. Fix that with the tool I already built for exactly this purpose: the Certificate Reader post. Generate a cert, grab the Base64 public key, paste it in. Done — Duende is running locally with real, signed-request authentication, and you didn't have to touch OpenSSL directly even once.

Azure Deployment

Time to get this off your laptop. Create a Web App in the Azure Portal on the Linux F1 (Free) plan — no reason to pay for compute on a POC. Once it's provisioned, browse to the default URL just to confirm it's alive and not just an expensive loading spinner.

Back in Visual Studio: right-click the solution, Publish, new profile, target Azure App Service (Linux), pick your web app, make sure the deploy mode is self-contained and linux-x64, then publish. Refresh the site, and there it is — your Identity Server, now living in the cloud, several time zones away from your laptop, exactly as intimidated by you as it was locally.

Docker and NGINX

This is the part that actually matters if you don't feel like redeploying every time you add a client.

Azure: change settings from the portal, easy. Local: open a text editor, easy. Docker: the settings are baked into the image, and rebuilding the whole image to add one client is the kind of tedium that makes people quit software. Enter external configuration — split the config into a behavior file and a clients file, then flip one flag:

{
  "IsContainerExternalConfiguration": true,
  "ContainerExternalConfiguration": "/appsettings/appsettings.external.duende.json",
  "IsUseReverseProxy": false,
  "ProxyBasePath": "/yourvirtualdirectory"
}

Mount an external folder in for local Docker testing (-v D:\configurations\duende:/app/settings/ in the container debug properties), confirm it works, and before building the real image, delete the keys folder you accumulated during local testing. Those signing keys get reused across runs — great for local dev, actively broken the moment you bake stale ones into a shared image. Skip this step and future you gets to enjoy a very confusing debugging session.

Push your own image, or just steal mine:

docker pull jtenorio/dseldonduendemain
docker run -d -e ASPNETCORE_URLS=http://*:3080 -p:3080:3080 -e IsContainerExternalConfiguration=true -e ContainerExternalConfiguration=/appsettings/appsettings.external.duende.json -e IsUseReverseProxy=false -v ~/appsettings:/appsettings/ jtenorio/dseldonduendemain

That starts the server on port 3080, points it at the external config file, and mounts your settings in from the host. Congratulations, Identity Server is now running on Linux, in Docker, without needing a rebuild every time you sneeze a new client into existence.

Realistically, though, most of you are running this behind NGINX or YARP, so here's the NGINX side:

location /yourids/ {
    proxy_pass http://127.0.0.1:3080/;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Fowarded-Proto https;
    proxy_set_header X-Fowarded-For $proxy_add_x_forwarded_for;
    sub_filter '<base href="/" />' '<base href="/yourids/" />';
}

That maps a virtual path to the container and forwards the headers it needs to behave. On the Docker side, flip IsUseReverseProxy to true, set ProxyBasePath, and enable forwarded headers — then reload NGINX (nginx -t && nginx -s reload) and you're live behind a proper reverse proxy instead of exposed on a bare port like it's 2004.

Long post — but you can skip 90% of it by just pulling my image and pointing NGINX at it. That's the whole point of publishing it in the first place.

Next time: Postman and signed requests. Happy coding!!!