<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Stefano Pinato</title>
    <description>The latest articles on DEV Community by Stefano Pinato (@_stefano_pinato).</description>
    <link>https://dev.to/_stefano_pinato</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4022944%2F894e64a7-845d-439a-85a6-39ca74159552.jpg</url>
      <title>DEV Community: Stefano Pinato</title>
      <link>https://dev.to/_stefano_pinato</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_stefano_pinato"/>
    <language>en</language>
    <item>
      <title>Why You Shouldn't Build Your Own Authentication From Scratch</title>
      <dc:creator>Stefano Pinato</dc:creator>
      <pubDate>Sun, 18 Oct 2026 16:03:26 +0000</pubDate>
      <link>https://dev.to/_stefano_pinato/why-you-shouldnt-build-your-own-authentication-from-scratch-893</link>
      <guid>https://dev.to/_stefano_pinato/why-you-shouldnt-build-your-own-authentication-from-scratch-893</guid>
      <description>&lt;h2&gt;
  
  
  The cost is not writing it, it is keeping it right
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A working login takes an afternoon. What costs money is everything that keeps it correct afterwards: password hashing that has to be tuned, keys that have to rotate, protocol guidance that changes, passkeys and recovery flows, abuse protection and an audit trail. This article lists those ongoing costs with sources, says honestly when building your own is the right call, and shows how to price the alternative.&lt;/p&gt;

&lt;h3&gt;
  
  
  What "from scratch" actually means
&lt;/h3&gt;

&lt;p&gt;The phrase covers three very different things, and most of the confusion in this debate comes from mixing them up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What you write&lt;/th&gt;
&lt;th&gt;What you keep maintaining&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hand-rolled&lt;/td&gt;
&lt;td&gt;Password hashing, sessions, token issuing, reset flows&lt;/td&gt;
&lt;td&gt;All of it, including the cryptographic choices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Framework building blocks (ASP.NET Core Identity, OpenIddict)&lt;/td&gt;
&lt;td&gt;The screens, the policy, the glue, and for a token server, the whole server around the library&lt;/td&gt;
&lt;td&gt;Configuration, upgrades, keys, recovery, operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packaged server or cloud service&lt;/td&gt;
&lt;td&gt;Client registrations and settings&lt;/td&gt;
&lt;td&gt;Configuration and upgrades, or a subscription&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;ASP.NET Core Identity is not "from scratch". Microsoft's documentation describes it as managing users, passwords, profile data, roles, claims, tokens and email confirmation, with support for external providers. If your need is one web application with its own user store, it is a perfectly reasonable foundation, and nothing below argues against it. The argument is about the point where that stops being enough, and about the second row of the table, where you still own a lot.&lt;/p&gt;

&lt;h3&gt;
  
  
  What sits under the login form
&lt;/h3&gt;

&lt;p&gt;Each item below is a piece of work that does not end when the feature ships.&lt;/p&gt;

&lt;h4&gt;
  
  
  Password storage that has to be upgraded
&lt;/h4&gt;

&lt;p&gt;OWASP's Password Storage Cheat Sheet covers Argon2id, scrypt, bcrypt and PBKDF2, and treats the work factor as something you raise over time as hardware gets cheaper. Its usual approach is to re-hash a password with the new settings the next time the user signs in. ASP.NET Core Identity's default hasher uses PBKDF2 with a default of 100,000 iterations, writes a version marker into every hash so older ones still verify, and can report that a correct password should be re-hashed. A hand-rolled scheme needs that upgrade path designed in from day one, which is easy to skip and hard to add later.&lt;/p&gt;

&lt;h4&gt;
  
  
  Keys that rotate, persist and survive a deployment
&lt;/h4&gt;

&lt;p&gt;ASP.NET Core's Data Protection system, which protects cookies and many other payloads, creates keys with a default lifetime of 90 days and rolls them automatically. It also documents the trap: unless the environment provides a persistent location, keys are not persisted outside the process, and in a container they should be stored on a volume or in an external provider. Deleting a key makes everything protected with it permanently unreadable. None of this is exotic, but each deployment target needs someone to have thought about it. A token server adds signing and encryption certificates on top, with their own expiry dates and their own rotation.&lt;/p&gt;

&lt;h4&gt;
  
  
  A protocol that keeps moving
&lt;/h4&gt;

&lt;p&gt;RFC 6749, the OAuth 2.0 core specification from 2012, was updated in January 2025 by RFC 9700, the current best-practice document for OAuth security, which also updates RFC 6750 and RFC 6819. It asks for exact redirect URI matching and PKCE for public clients, advises against the implicit grant and forbids the password grant. An implementation that was reasonable when you wrote it can drift out of line without a single line of your code changing. I walked through the practical consequences in &lt;a href="https://identitysuite.net/blog/identitysuite/oauth-misconfiguration" rel="noopener noreferrer"&gt;What happens when your authentication is misconfigured&lt;/a&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Passkeys, second factors and the recovery question
&lt;/h4&gt;

&lt;p&gt;.NET 10 added passkey support to ASP.NET Core Identity, which is a real step forward, and its documentation is candid about the edges. Passkeys are treated as a primary sign-in factor, not as a second factor. Attestation statements are not validated by default. Only the Blazor Web App template includes passkey support out of the box. For apps that allow passkeys as the only credential, recovery is left to you: the documentation suggests recovery codes, email-based flows or requiring several passkeys. It also notes that you may need to revoke credentials if a particular authenticator model turns out to be vulnerable. Microsoft's multi-factor authentication documentation adds that SMS as a second factor is no longer recommended, pointing to NIST's digital identity guidelines. Choices like these are product decisions that someone has to own and revisit.&lt;/p&gt;

&lt;h4&gt;
  
  
  Abuse protection and an audit trail
&lt;/h4&gt;

&lt;p&gt;Lockout, rate limiting and detection of credential stuffing are not features you build once. They need tuning against real traffic, and OWASP keeps a separate cheat sheet on credential stuffing prevention for a reason. When something does go wrong you want to answer who signed in, who failed, which account was locked and which tokens were revoked. That means recording security events deliberately, from the start, in a form you can query.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The pattern behind the list&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every item has a build cost, which is visible, and a maintenance cost, which is not. The maintenance cost is paid by whoever is on the team in two years' time, usually without having seen the original decision.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  When building it yourself is the right call
&lt;/h3&gt;

&lt;p&gt;It is a short list, but it is not empty:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One application, one user store, no third-party clients.&lt;/strong&gt; ASP.NET Core Identity alone is enough, it is maintained by Microsoft, and adding an OpenID Connect server would be over-engineering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication is your product.&lt;/strong&gt; If you are building an identity provider, you own all of it by definition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A constraint that rules out third-party components.&lt;/strong&gt; Some environments do not allow them. Then the maintenance list above is the budget you are signing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learning.&lt;/strong&gt; Building a small authorization server once is the best way to understand one. Just do not point real users at it.
What changes the answer is growth in the requirements: a second application that should share the sign-in, a mobile client, an API consumed by someone else, partners who need tokens. At that point you are no longer adding a login to an app, you are operating a token server, and the second row of the table above becomes a project.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Buying is not free of work either
&lt;/h3&gt;

&lt;p&gt;A packaged server or a cloud service moves the maintenance, it does not remove it. You still configure it, upgrade it and watch what it does. And you take on vendor risk, which is real: &lt;a href="https://identitysuite.net/blog/identitysuite/identityserver4-end-of-life" rel="noopener noreferrer"&gt;IdentityServer4's end of life&lt;/a&gt; left many teams with an unpatched server in front of their login. Before you choose anything, check who maintains it, how recently it was updated and what happens to you if that stops.&lt;/p&gt;

&lt;p&gt;For a .NET team that wants a self-hosted token server with the work above already done, IdentitySuite (which I build, so weigh this part accordingly) is one option. It is built on OpenIddict and ASP.NET Core Identity, and the settings files that ship with it include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Passkey sign-in, switched on in the shipped templates, alongside authenticator-app two-factor authentication and recovery codes.&lt;/li&gt;
&lt;li&gt;Sign-in with Google, Microsoft, Facebook, GitHub, LinkedIn and Apple.&lt;/li&gt;
&lt;li&gt;Lockout after repeated failures and rate limiting per endpoint class, both enabled in the templates.&lt;/li&gt;
&lt;li&gt;Built-in signing and encryption certificates that are rotated automatically as they near expiry, with an expiry alert, instead of keys you manage by hand.&lt;/li&gt;
&lt;li&gt;A security audit log with sign-in successes and failures, lockouts, two-factor events, passkey events and token revocations.
It does not remove the maintenance entirely: you still run it, configure it and keep it updated, and it is a younger product with a smaller ecosystem than the established ones. If you want to see what adopting it looks like, the &lt;a href="https://identitysuite.net/blog/identitysuite/authentication-made-easy-guide" rel="noopener noreferrer"&gt;Authentication Made Easy&lt;/a&gt; article adds authentication to a single-page app with it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How to price the decision
&lt;/h3&gt;

&lt;p&gt;I will not invent a number of person-months for you; it depends on your team and your requirements. The comparison is easier than it looks, though. List the capabilities above that you need, estimate the first build and the yearly upkeep for each in hours, and multiply by your own hourly cost.&lt;/p&gt;

&lt;p&gt;Then compare that with the price of the alternative. IdentitySuite's Standard plan is €349 per year. As an illustration only, at an assumed €60 per hour that is under six hours of one developer's time. The question is whether you would spend more than that in a year on key rotation, upgrades, checking security guidance and reading audit logs, before writing any feature at all. Use your own rate; the shape of the answer rarely changes.&lt;/p&gt;

&lt;p&gt;If you want to see what each plan includes, &lt;a href="https://identitysuite.net/pricing" rel="noopener noreferrer"&gt;the pricing page&lt;/a&gt; has the details.&lt;/p&gt;

</description>
      <category>security</category>
      <category>authentication</category>
      <category>dotnet</category>
      <category>aspnetcore</category>
    </item>
    <item>
      <title>What Happens When Your Authentication Is Misconfigured</title>
      <dc:creator>Stefano Pinato</dc:creator>
      <pubDate>Sun, 04 Oct 2026 19:00:26 +0000</pubDate>
      <link>https://dev.to/_stefano_pinato/what-happens-when-your-authentication-is-misconfigured-37kh</link>
      <guid>https://dev.to/_stefano_pinato/what-happens-when-your-authentication-is-misconfigured-37kh</guid>
      <description>&lt;h2&gt;
  
  
  Six settings that decide whether an attack works
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Some of the most common weaknesses in a login system are not bugs in a framework, they are settings: a redirect URI that matches too loosely, a client that is allowed to skip PKCE, a token that stays valid for a day. This article walks through six of them — what each one lets an attacker do, how to check it on your own server in a few minutes, and what the settings files that ship with IdentitySuite do about it. One of the six, CORS, is not locked down by default, and I say so where it comes up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why configuration is the weak point
&lt;/h3&gt;

&lt;p&gt;OAuth 2.0 and OpenID Connect are flexible on purpose. The same server can issue tokens to a browser app, a phone, a backend service and a TV, and each of those needs different rules. The protocol leaves those rules to the person configuring the server, and a server enforces only what it has been told to enforce.&lt;/p&gt;

&lt;p&gt;The IETF's current security guidance, RFC 9700 (&lt;em&gt;Best Current Practice for OAuth 2.0 Security&lt;/em&gt;, published January 2025), reads largely as a list of settings: match this exactly, require that, do not use the other. Four of the six topics below — redirect URIs, PKCE, token lifetimes and the implicit flow — are addressed directly in it. The other two, CORS and client secrets, are deployment mistakes rather than protocol ones.&lt;/p&gt;

&lt;p&gt;If you configure OpenIddict yourself, the &lt;a href="https://identitysuite.net/blog/identitysuite/openiddict-configuration-pitfalls" rel="noopener noreferrer"&gt;pitfalls article&lt;/a&gt; covers the same ground from the code side. Here the angle is the attacker's: what does each mistake buy them, and how do you find out whether you have it?&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Redirect URIs that match too loosely
&lt;/h3&gt;

&lt;p&gt;After you sign in, the authorization server sends the authorization code to the client's &lt;code&gt;redirect_uri&lt;/code&gt;. If the server accepts anything that merely resembles the registered value — a prefix, a wildcard, a different case, any path under the same host — the attacker gets to choose where the code goes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the attack looks like.&lt;/strong&gt; The attacker builds a link to your real login page with a &lt;code&gt;redirect_uri&lt;/code&gt; pointing somewhere they control, or at a page on your own domain that forwards visitors elsewhere. The user signs in on your genuine page, and the code lands with the attacker. RFC 9700 requires authorization servers to compare redirect URIs against the registered ones with exact string matching, with one exception: the port of a loopback address in a native app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check.&lt;/strong&gt; Take a URI you have registered and request it with small changes: an extra path segment, different letter case, an added query string, another port, a subdomain, &lt;code&gt;http&lt;/code&gt; instead of &lt;code&gt;https&lt;/code&gt;. Every variant must be refused &lt;em&gt;before&lt;/em&gt; the login page appears. Then read through the registered URIs of your production clients and look for &lt;code&gt;localhost&lt;/code&gt;, plain &lt;code&gt;http&lt;/code&gt; and anything that looks like a pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What IdentitySuite does.&lt;/strong&gt; The client form stores each redirect URI as a complete absolute URI, one entry per URI; there is no field for a pattern. Matching is done by OpenIddict, which compares the values as case-sensitive strings and relaxes only the port of a loopback address, and only for clients registered as native applications. That is the behaviour RFC 9700 asks for. What exact matching cannot do is tell you that a registered URI should not be there: a leftover &lt;code&gt;https://localhost&lt;/code&gt; entry on a production client is matched exactly like any other.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Open redirectors cancel exact matching&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a page on your client, correctly registered as a redirect URI, forwards the browser to whatever address a query parameter says, the code can still be carried out through it. RFC 9700 tells clients not to expose such redirectors. Exact matching on the server is only half of the defence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2. PKCE supported but not required
&lt;/h3&gt;

&lt;p&gt;PKCE ties an authorization code to the client instance that started the flow. The client sends a hash of a secret value when it begins (&lt;code&gt;code_challenge&lt;/code&gt;) and the secret itself when it redeems the code (&lt;code&gt;code_verifier&lt;/code&gt;). A server that &lt;em&gt;supports&lt;/em&gt; PKCE will honour it when a client uses it. A server that &lt;em&gt;requires&lt;/em&gt; it will refuse the request when the challenge is missing. Those are different security properties.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the attack looks like.&lt;/strong&gt; Authorization codes leak in more places than people expect: another app registered for the same custom scheme on a phone, a browser extension, a proxy log, a compromised hop in a redirect chain. Without PKCE, whoever holds the code can exchange it for tokens. With PKCE required, a stolen code is useless without the verifier. If PKCE is only optional, the attacker can craft the sign-in link themselves and leave the challenge out. RFC 9700 says public clients must use PKCE and recommends it for confidential clients too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check.&lt;/strong&gt; Send an authorization request that has everything except &lt;code&gt;code_challenge&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://id.example.com/connect/authorize?client_id=spa&amp;amp;response_type=code
    &amp;amp;redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
    &amp;amp;scope=openid&amp;amp;state=abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should get an error response, not a login page. If you get the login page, any code issued for that client can be redeemed by whoever holds it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What IdentitySuite does.&lt;/strong&gt; The settings files that ship with the product set &lt;code&gt;RequireProofKeyForCodeExchange&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; in the Development, Staging and Production templates, and the admin UI's single-page app, web and native client templates also add the PKCE requirement to the client itself. One caveat: the default of that option in the code is &lt;code&gt;false&lt;/code&gt;. The protection comes from the settings file, so if you start from your own file rather than the shipped one, check the value. OpenIddict itself does not enforce PKCE unless you tell it to, either globally or per client.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Tokens that live too long and cannot be revoked
&lt;/h3&gt;

&lt;p&gt;A bearer token works for whoever holds it. Its lifetime is the window an attacker has after a leak, and a self-contained JWT cannot be recalled before it expires. Refresh tokens matter even more: a refresh token that lasts weeks and can be reused indefinitely is a long-lived credential sitting on a device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the attack looks like.&lt;/strong&gt; A token ends up in a log, a crash report or a browser profile that is later copied. With a 24-hour access token the attacker has a day; with a refresh token that never rotates, they can keep minting new access tokens until it expires. RFC 9700 points out that refresh tokens exist so the authorization server can issue access tokens with a short lifetime, and requires refresh tokens for public clients to be either sender-constrained or rotated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check.&lt;/strong&gt; If you use JWT access tokens, decode one and subtract &lt;code&gt;iat&lt;/code&gt; from &lt;code&gt;exp&lt;/code&gt;; then ask what you would do if that token leaked right now. Use a refresh token twice: after rotation the second use should fail, allowing for a short grace period that exists for concurrent requests. Finally revoke a token and call your API with it. If the call still succeeds, revocation is not doing what you think it does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What IdentitySuite does.&lt;/strong&gt; The shipped settings use an access token lifetime of 30 minutes, an identity token lifetime of 30 minutes, an authorization code lifetime of 5 minutes and a refresh token lifetime of 14 days. Access and refresh tokens are reference tokens: the client holds an opaque handle and the token itself stays in the database, so revoking one is a change to a database record. Access token encryption is on. OpenIddict rotates refresh tokens by default — each use redeems the old one and issues a new one — and IdentitySuite does not turn that off.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;What reference tokens cost&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An API that receives an opaque token cannot validate it locally; it asks the authorization server through introspection, which is a database lookup per request. That is the price of being able to revoke instantly, and the &lt;a href="https://identitysuite.net/blog/identitysuite/protect-dotnet-api-with-tokens" rel="noopener noreferrer"&gt;article on protecting a Web API&lt;/a&gt; shows how the validation side looks. Thirty minutes is a default, not a recommendation: for a sensitive API, lower it. For background on the token formats themselves, see &lt;a href="https://identitysuite.net/blog/identitysuite/what-are-jwt-tokens" rel="noopener noreferrer"&gt;What are JWT tokens&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  4. CORS that allows every origin
&lt;/h3&gt;

&lt;p&gt;Cross-origin resource sharing tells a browser which other sites' scripts may read responses from your server. A wildcard origin is not an exploit on its own — browsers will not combine it with credentialed requests — but it widens what any web page can read from your server on behalf of a visitor. The dangerous variant is a server that echoes back whatever &lt;code&gt;Origin&lt;/code&gt; it receives &lt;em&gt;and&lt;/em&gt; allows credentials: any site a signed-in user visits can then read authenticated responses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it means for an identity server.&lt;/strong&gt; Its discovery document and key set are public by design, and single-page apps legitimately call the token endpoint from the browser. The more realistic damage is the habit of copying a permissive policy onto the APIs and admin endpoints that sit next to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check.&lt;/strong&gt; Send a request with an origin that should not be allowed and look at the response headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-si&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Origin: https://evil.example"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://id.example.com/.well-known/openid-configuration | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"access-control"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeat it for the token endpoint and for any endpoint that uses cookies. You are looking at &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; and &lt;code&gt;Access-Control-Allow-Credentials&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What IdentitySuite does.&lt;/strong&gt; This is the one default I would not call locked down. The &lt;code&gt;Cors&lt;/code&gt; section of the shipped settings has an empty &lt;code&gt;AllowedOrigins&lt;/code&gt; list, and when that list is empty the server allows any origin. Credentials are not allowed by default, so the combination that makes a wildcard dangerous is absent. In production you should still list the origins you actually use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Cors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AllowedOrigins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://app.example.com"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AllowCredentials"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Secrets where anyone can read them
&lt;/h3&gt;

&lt;p&gt;OAuth divides clients into two kinds. A &lt;em&gt;confidential&lt;/em&gt; client can keep a secret: a server-rendered web app, a backend service. A &lt;em&gt;public&lt;/em&gt; client cannot: a single-page app, a mobile or desktop app. Anything you ship to a user's device is readable by that user, so a client secret inside a JavaScript bundle or an app package is not a secret.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the attack looks like.&lt;/strong&gt; Someone opens the bundle, finds the &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt;, and can now authenticate to your server as that client — wherever the server relies on the secret as proof of identity. The fix is not to hide the secret better. Public clients should not have one; they rely on PKCE instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check.&lt;/strong&gt; Search your front-end build output, your mobile app package and your repository history for &lt;code&gt;client_secret&lt;/code&gt; and for the secret values themselves. Then list the clients on your server and compare each one's type with where it actually runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What IdentitySuite does.&lt;/strong&gt; The admin UI distinguishes public from confidential clients. The single-page app and native app templates create public clients with no secret and PKCE required; the machine-to-machine template creates a confidential one. The form cannot see your JavaScript bundle, though: if a confidential client's secret gets pasted into front-end code, nothing on the server will notice.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. The implicit flow and the password grant still switched on
&lt;/h3&gt;

&lt;p&gt;The implicit flow returns tokens directly in the redirect, in the URL fragment. It was invented when browsers could not make cross-origin requests to a token endpoint; they can now, and PKCE exists. The resource owner password credentials grant hands the user's actual password to the client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the attack looks like.&lt;/strong&gt; Tokens in a URL end up in browser history, referrer headers, logs and extensions, and nothing binds them to the client that received them. RFC 9700 says clients should not use the implicit grant or any response type that issues access tokens in the authorization response, and that the password grant must not be used at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check.&lt;/strong&gt; Fetch the discovery document and read two lists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://id.example.com/.well-known/openid-configuration
&lt;span class="c"&gt;# look at: response_types_supported, grant_types_supported&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;id_token token&lt;/code&gt; or &lt;code&gt;password&lt;/code&gt; appears there, at least one client can be enabled to use them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What IdentitySuite does.&lt;/strong&gt; The shipped settings set &lt;code&gt;AllowImplicitFlow&lt;/code&gt; and &lt;code&gt;AllowPasswordFlow&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;. There are two layers: the server-level switch decides whether a flow exists at all, and a per-client permission decides which clients may use it — OpenIddict rejects a request for a feature the client has not been granted. The admin UI still offers a legacy single-page app template that configures the implicit flow, for applications that cannot move yet; it needs the server-level switch on as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  The six checks on one page
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Quick check&lt;/th&gt;
&lt;th&gt;IdentitySuite shipped settings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Redirect URIs&lt;/td&gt;
&lt;td&gt;Request altered variants of a registered URI&lt;/td&gt;
&lt;td&gt;Full URIs only, exact matching by OpenIddict. Review leftovers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PKCE&lt;/td&gt;
&lt;td&gt;Authorization request without &lt;code&gt;code_challenge&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Required server-wide in all three templates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token lifetimes&lt;/td&gt;
&lt;td&gt;Subtract &lt;code&gt;iat&lt;/code&gt; from &lt;code&gt;exp&lt;/code&gt;; reuse a refresh token; revoke and retry&lt;/td&gt;
&lt;td&gt;30 min access, 14 days refresh, reference tokens, rotation on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CORS&lt;/td&gt;
&lt;td&gt;Request with a foreign &lt;code&gt;Origin&lt;/code&gt; header&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Not restricted:&lt;/strong&gt; empty list means any origin, credentials off. Set it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client secrets&lt;/td&gt;
&lt;td&gt;Search bundles, packages and repository history&lt;/td&gt;
&lt;td&gt;SPA and native templates are public, no secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Implicit / password&lt;/td&gt;
&lt;td&gt;Read the discovery document&lt;/td&gt;
&lt;td&gt;Both off at server level&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Defaults help, but they are not a review
&lt;/h3&gt;

&lt;p&gt;A settings file is a starting point. Teams copy it between environments, edit it under pressure and forget what changed, so run these checks again after any change to the authorization server, not just once at launch. The six here are the ones I would look at first; they are not a complete list.&lt;/p&gt;

&lt;p&gt;Everything above assumes the server you run is still receiving fixes. If you are on IdentityServer4, that assumption no longer holds, and &lt;a href="https://identitysuite.net/blog/identitysuite/identityserver4-end-of-life" rel="noopener noreferrer"&gt;this comparison of your options&lt;/a&gt; is the place to start. If you want the background on the protocol behind all of this, the &lt;a href="https://identitysuite.net/blog/identitysuite/oauth2-explained" rel="noopener noreferrer"&gt;OAuth 2.0 explainer&lt;/a&gt; covers the flow step by step.&lt;/p&gt;

&lt;p&gt;Want to run these checks against a server of your own? &lt;a href="https://identitysuite.net/documentation/GetStarted" rel="noopener noreferrer"&gt;Get started with IdentitySuite for free&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>oauth</category>
      <category>dotnet</category>
      <category>authentication</category>
    </item>
    <item>
      <title>IdentityServer4 Is End-of-Life — Your Options in 2026</title>
      <dc:creator>Stefano Pinato</dc:creator>
      <pubDate>Mon, 21 Sep 2026 20:13:41 +0000</pubDate>
      <link>https://dev.to/_stefano_pinato/identityserver4-is-end-of-life-your-options-in-2026-2ddp</link>
      <guid>https://dev.to/_stefano_pinato/identityserver4-is-end-of-life-your-options-in-2026-2ddp</guid>
      <description>&lt;h2&gt;
  
  
  Your options in 2026
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; IdentityServer4 has been out of support since December 2022 and its GitHub repository was archived in March 2025. If you are still running it in production, you are running unpatched, security-critical infrastructure. This article compares six real replacement paths — Duende IdentityServer, Open.IdentityServer, OpenIddict, Keycloak, a cloud identity provider, and IdentitySuite — on cost, effort, stack fit, and data control, with sourced numbers so you can make the call yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  What "end of life" actually means for a security-critical component
&lt;/h3&gt;

&lt;p&gt;IdentityServer4's own archived repository states it plainly: the project "went out of support when .NET Core 3.1 end of support was reached (13th Dec 2022)." It was forked as Duende IdentityServer and, as the same notice explains, "contains multiple known security vulnerabilities and bugs, and has outdated documentation." The repository itself was archived on 6th March 2025 — read-only, no further commits, ever.&lt;/p&gt;

&lt;p&gt;This is not a library that is merely "unmaintained" in the sense of slow releases. It is the authorization server sitting in front of your login, your tokens, and your session management — with known, unpatched vulnerabilities and no one watching for new ones.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Why this is worse than a typical outdated dependency&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An outdated logging library fails loudly. An outdated authorization server fails silently — it keeps issuing tokens, keeps authenticating users, and gives no indication that a known exploit for a redirect URI bypass or a token validation flaw applies to your exact deployment, because no one is publishing patches to compare against anymore.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The six options, compared
&lt;/h3&gt;

&lt;p&gt;All prices and figures below are as verified in September 2026, sourced from each vendor's own pricing and product pages — check current figures before deciding, since commercial pricing changes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Effort to get productive&lt;/th&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;Data control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Duende IdentityServer&lt;/td&gt;
&lt;td&gt;$5,750–$24,900/yr, or free Community Edition if you qualify&lt;/td&gt;
&lt;td&gt;Low-medium — closest conceptual match to IS4&lt;/td&gt;
&lt;td&gt;.NET&lt;/td&gt;
&lt;td&gt;Full (self-hosted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open.IdentityServer&lt;/td&gt;
&lt;td&gt;Free core; admin UI and add-ons £2,528–£8,932/yr&lt;/td&gt;
&lt;td&gt;Medium — core is free, tooling is a la carte&lt;/td&gt;
&lt;td&gt;.NET&lt;/td&gt;
&lt;td&gt;Full (self-hosted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenIddict (DIY)&lt;/td&gt;
&lt;td&gt;Free (Apache-2.0)&lt;/td&gt;
&lt;td&gt;High — you build and secure everything yourself&lt;/td&gt;
&lt;td&gt;.NET&lt;/td&gt;
&lt;td&gt;Full (self-hosted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keycloak&lt;/td&gt;
&lt;td&gt;Free (open source)&lt;/td&gt;
&lt;td&gt;Medium-high — capable, but a second stack to operate&lt;/td&gt;
&lt;td&gt;Java / JVM&lt;/td&gt;
&lt;td&gt;Full (self-hosted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud IdP (Auth0, Entra)&lt;/td&gt;
&lt;td&gt;Free tier, then usage-based (e.g. Auth0 from $35/mo, scales with MAU)&lt;/td&gt;
&lt;td&gt;Low — fastest to a working login&lt;/td&gt;
&lt;td&gt;Vendor-managed&lt;/td&gt;
&lt;td&gt;Limited — data lives with the vendor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IdentitySuite&lt;/td&gt;
&lt;td&gt;Free (3 clients) / €349/yr / €1,499/yr — flat&lt;/td&gt;
&lt;td&gt;Low — admin UI, email and theming included&lt;/td&gt;
&lt;td&gt;.NET&lt;/td&gt;
&lt;td&gt;Full (self-hosted)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Duende IdentityServer — the official successor
&lt;/h3&gt;

&lt;p&gt;Duende IdentityServer is built by the original authors of IdentityServer4 and is the direct, spec-compliant continuation of that project. If you want the path with the least conceptual distance from what you already have, this is it — the configuration model, the extensibility points, and the mental model all carry over closely.&lt;/p&gt;

&lt;p&gt;It is a commercial product. As of September 2026, published annual pricing runs Lite at $5,750 (1 production deployment, 2 client IDs), Standard at $12,500 (10 client IDs, server-side sessions), and Advanced at $24,900 (30 client IDs, SAML, automatic key management), with a Custom tier above that. There is also a free &lt;strong&gt;Community Edition&lt;/strong&gt; — feature-equivalent to Standard, not a crippled trial — for organizations under $1M USD projected annual revenue and $3M USD in capital access, or non-profits with a published annual budget under $1M USD. When you outgrow those thresholds, you move to a paid tier on the same platform.&lt;/p&gt;

&lt;p&gt;The honest trade-off: Duende gives you the framework, not a finished product. Admin UI, theming, and email templates are not included — you build them or buy them separately (commonly from third-party vendors in the ecosystem). For a team that wants to own that layer, this is a feature. For a team that just wants working authentication, it is additional scope.&lt;/p&gt;

&lt;h3&gt;
  
  
  Open.IdentityServer — the free, open-source fork
&lt;/h3&gt;

&lt;p&gt;Open.IdentityServer is a separate project from Rock Solid Knowledge (RSK), a .NET consultancy with a long history in the IdentityServer ecosystem. It continues the Apache-2.0-licensed IdentityServer4 codebase as a free, open-source SDK — not a recompilation, but an actively maintained fork with new fixes and features. RSK is explicit that the project is &lt;strong&gt;not affiliated with or endorsed by Duende Software&lt;/strong&gt;, which is a credible, honest disclosure rather than a red flag.&lt;/p&gt;

&lt;p&gt;The free-forever claim is real, but it covers the protocol SDK only — there is no admin UI, email builder, or theme builder included. Those come from RSK's separate commercial add-ons: a standalone AdminUI license runs £2,528/yr (Enterprise, unlimited users and clients, one production environment) or £8,932/yr (Universal, multiple environments), and a "Supporter License" bundling AdminUI plus passkeys, SAML, SCIM, WS-Fed, key rotation and policy-based access control costs £5,750/yr for everything. Once you add a management UI to the free core, the total is comparable to — sometimes above — what a batteries-included commercial product costs.&lt;/p&gt;

&lt;p&gt;It is a legitimate option if you specifically want an Apache-licensed, community-auditable continuation of the IS4 codebase and are prepared to assemble the rest of the stack yourself or pay for RSK's components.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenIddict — free, flexible, and entirely on you
&lt;/h3&gt;

&lt;p&gt;OpenIddict is a free, Apache-2.0-licensed OAuth 2.0/OpenID Connect framework for .NET, and it is what IdentitySuite itself is built on. There is no license cost and no vendor to negotiate with. The trade-off is that OpenIddict is a toolkit, not a product: there is no admin UI, no user management screens, no email flows — you write all of it, and you are responsible for getting the security-sensitive parts right.&lt;/p&gt;

&lt;p&gt;I covered exactly how much can go wrong in that process in a &lt;a href="https://identitysuite.net/blog/identitysuite/openiddict-configuration-pitfalls" rel="noopener noreferrer"&gt;dedicated article&lt;/a&gt; — development certificates left in production, missing PKCE enforcement, loose redirect URI validation. None of these are bugs in OpenIddict; they are the cost of the flexibility it gives you. If your team has the time and the security expertise to configure it correctly and keep it that way, this is the cheapest option on the list in license terms. It is rarely the cheapest in engineering time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keycloak — powerful, free, and a different stack
&lt;/h3&gt;

&lt;p&gt;Keycloak is a mature, free, open-source identity and access management platform with a large feature set: SSO, identity brokering, fine-grained authorization, and a large plugin ecosystem. For organizations that already run Java/JVM infrastructure and have operations capacity for it, it is a genuinely strong choice with no license cost.&lt;/p&gt;

&lt;p&gt;For a .NET team, the honest downside is operational: you are adding a second runtime and deployment model to maintain, with upgrade cycles, resource requirements, and theming conventions that follow Java tooling patterns rather than the ones your team already uses day to day.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud identity providers — fastest start, ongoing usage cost
&lt;/h3&gt;

&lt;p&gt;Auth0 and Microsoft Entra External ID are the two most common cloud options. Auth0's current pricing (September 2026) includes a free tier up to 25,000 monthly active users, after which paid plans start at $35/month and scale with MAU and feature tier, up to custom enterprise pricing. There is no infrastructure to run, patch, or scale — you get a working login in hours, not weeks.&lt;/p&gt;

&lt;p&gt;The trade-off is the one every per-MAU pricing model shares: cost grows with your user base, and your identity data lives in the vendor's infrastructure rather than yours. For products with a small, stable user count this is rarely an issue. For products expecting significant user growth, or with data residency requirements, it is worth modeling the cost curve before committing.&lt;/p&gt;

&lt;h3&gt;
  
  
  IdentitySuite — self-hosted, .NET-native, flat pricing
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://identitysuite.net" rel="noopener noreferrer"&gt;IdentitySuite&lt;/a&gt; is built on the same foundation as the DIY path above — OpenIddict and ASP.NET Core Identity — but ships the admin UI, email template builder, theme builder, and passkey support as part of the product, not as something you build or buy separately. It is self-hosted, so your data stays on your infrastructure, and licensing is flat rather than usage-based: free for up to 3 clients, €349/year (Standard) or €1,499/year (Enterprise), with no per-user or per-MAU cost.&lt;/p&gt;

&lt;p&gt;The trade-off, stated plainly: it is a younger product than Duende or Keycloak, with a smaller ecosystem and community around it. If you need SAML, deep extensibility hooks accumulated over a decade, or a large plugin marketplace today, that maturity gap is real and worth weighing. What you get in exchange is a working authorization server with an admin UI on day one, for a .NET team that does not want to own the configuration surface described in the OpenIddict section above.&lt;/p&gt;

&lt;h3&gt;
  
  
  A framework for deciding
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want the officially-blessed successor and can absorb commercial pricing&lt;/strong&gt; (or qualify for the free Community Edition) → Duende IdentityServer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a free, Apache-licensed continuation of the exact IS4 codebase&lt;/strong&gt; and are willing to assemble or buy the rest of the stack separately → Open.IdentityServer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You have the engineering time and security expertise to own every configuration decision&lt;/strong&gt;, and license cost is the priority → OpenIddict, direct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your organization already runs Java/Kubernetes infrastructure with ops capacity&lt;/strong&gt; → Keycloak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want zero infrastructure to manage&lt;/strong&gt; and a usage-based cost curve fits your growth plan → a cloud identity provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are a .NET team that wants a self-hosted, batteries-included product at flat, predictable pricing&lt;/strong&gt; → IdentitySuite.
Whichever path you choose, the one option that is no longer on the table is staying on IdentityServer4. If you want the deeper background on the framework several of these options build on, &lt;a href="https://identitysuite.net/blog/identitysuite/what-is-openiddict" rel="noopener noreferrer"&gt;What is OpenIddict?&lt;/a&gt; is a good starting point.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ready to see the self-hosted, flat-pricing option in practice? &lt;a href="https://identitysuite.net/documentation/GetStarted" rel="noopener noreferrer"&gt;Get started with IdentitySuite for free&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>security</category>
    </item>
    <item>
      <title>Machine-to-machine authentication — when your services need to talk without a user</title>
      <dc:creator>Stefano Pinato</dc:creator>
      <pubDate>Mon, 14 Sep 2026 17:01:04 +0000</pubDate>
      <link>https://dev.to/_stefano_pinato/machine-to-machine-authentication-when-your-services-need-to-talk-without-a-user-55i8</link>
      <guid>https://dev.to/_stefano_pinato/machine-to-machine-authentication-when-your-services-need-to-talk-without-a-user-55i8</guid>
      <description>&lt;h2&gt;
  
  
  When your services need to talk without a user
&lt;/h2&gt;

&lt;p&gt;You have two backend services. One needs to call the other securely — no browser involved, no user clicking a login button, no redirect flow. Just service A proving its identity to service B and getting access to what it needs.&lt;/p&gt;

&lt;p&gt;This is machine-to-machine (M2M) authentication, and it is one of the most common scenarios in modern distributed systems. OAuth 2.0 has a grant type designed exactly for this: the &lt;strong&gt;Client Credentials flow&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What makes M2M different
&lt;/h3&gt;

&lt;p&gt;Most OAuth 2.0 flows involve a user: someone clicks a button, authenticates, and grants consent. The resulting token represents that user's identity and permissions.&lt;/p&gt;

&lt;p&gt;In M2M scenarios, there is no user. Service A is acting on its own behalf — not on behalf of any person. The token it receives represents the service itself, not a human identity. This means no login screen, no consent dialog, and no redirect. The exchange happens entirely between the service and the authorization server, programmatically and invisibly.&lt;/p&gt;

&lt;p&gt;This is also a case where you need plain OAuth 2.0 — no OpenID Connect layer required, because there is no user identity to establish.&lt;/p&gt;

&lt;h3&gt;
  
  
  The flow in plain terms
&lt;/h3&gt;

&lt;p&gt;The Client Credentials flow is the simplest OAuth 2.0 flow. It involves three steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Service A authenticates with the authorization server&lt;/strong&gt; — using its own client ID and client secret, it requests an access token for a specific scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The authorization server verifies the credentials and issues a token&lt;/strong&gt; — if the client ID and secret are valid and the requested scope is allowed, a token is returned. No user involved, no consent required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service A calls Service B with the token&lt;/strong&gt; — the token travels in the &lt;code&gt;Authorization: Bearer&lt;/code&gt; header, and Service B validates it before processing the request.
&amp;gt; 💡 &lt;strong&gt;Token caching matters&lt;/strong&gt;
&amp;gt;
&amp;gt; Unlike user tokens, service tokens can and should be cached. Requesting a new token for every API call adds unnecessary latency and load on the authorization server. Cache the token and refresh it only when it is about to expire.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 1 — configure the client in IdentitySuite
&lt;/h3&gt;

&lt;p&gt;Before writing any code, register Service A as a client in &lt;a href="https://identitysuite.net" rel="noopener noreferrer"&gt;IdentitySuite&lt;/a&gt; with the Client Credentials flow enabled, and define which scopes it is allowed to request. This is done through the admin UI — no code required.&lt;/p&gt;

&lt;p&gt;Once registered, you will have a client ID and client secret for Service A, and a scope that Service B will use to validate incoming requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — requesting a token from Service A
&lt;/h3&gt;

&lt;p&gt;Service A needs to obtain a token before calling Service B. The cleanest way to handle this in .NET is to use a typed &lt;code&gt;HttpClient&lt;/code&gt; with a delegating handler that automatically acquires and caches the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;appsettings.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AuthorizationServer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Authority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://your-identitysuite-instance.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ClientId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"service-a-client-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ClientSecret"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"service-a-client-secret"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"service-b-scope"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Program.cs — Service A&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ServiceB"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseAddress&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://service-b.internal"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TokenService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// TokenService.cs — acquires and caches the access token&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TokenService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IHttpClientFactory&lt;/span&gt; &lt;span class="n"&gt;_factory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IConfiguration&lt;/span&gt; &lt;span class="n"&gt;_config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_cachedToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;_tokenExpiry&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;TokenService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IHttpClientFactory&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IConfiguration&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_factory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetTokenAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_cachedToken&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;_tokenExpiry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_cachedToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;authority&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AuthorizationServer:Authority"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;authority&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/connect/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;FormUrlEncodedContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"grant_type"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"client_credentials"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"client_id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AuthorizationServer:ClientId"&lt;/span&gt;&lt;span class="p"&gt;]!,&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"client_secret"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AuthorizationServer:ClientSecret"&lt;/span&gt;&lt;span class="p"&gt;]!,&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AuthorizationServer:Scope"&lt;/span&gt;&lt;span class="p"&gt;]!&lt;/span&gt;
            &lt;span class="p"&gt;}));&lt;/span&gt;

        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnsureSuccessStatusCode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JsonElement&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
        &lt;span class="n"&gt;_cachedToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;GetString&lt;/span&gt;&lt;span class="p"&gt;()!;&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;expiresIn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;GetInt32&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;_tokenExpiry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expiresIn&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 30s buffer&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_cachedToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3 — calling Service B with the token
&lt;/h3&gt;

&lt;p&gt;Once the token is available, attaching it to the outgoing request is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ServiceBClient&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IHttpClientFactory&lt;/span&gt; &lt;span class="n"&gt;_factory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;TokenService&lt;/span&gt; &lt;span class="n"&gt;_tokenService&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;ServiceBClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IHttpClientFactory&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TokenService&lt;/span&gt; &lt;span class="n"&gt;tokenService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_factory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_tokenService&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokenService&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetDataAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_tokenService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetTokenAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ServiceB"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultRequestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Authorization&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AuthenticationHeaderValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetStringAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/data"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4 — validating the token in Service B
&lt;/h3&gt;

&lt;p&gt;Service B validates the incoming token using OpenIddict introspection, exactly as described in &lt;a href="https://dev.to/_stefano_pinato"&gt;protecting a .NET Web API with tokens&lt;/a&gt;. The only difference is that the claims available in &lt;code&gt;HttpContext.User&lt;/code&gt; will reflect the service identity rather than a user identity — there is no &lt;code&gt;name&lt;/code&gt; or &lt;code&gt;email&lt;/code&gt;, but there is a &lt;code&gt;sub&lt;/code&gt; claim identifying the client, and the scopes granted to it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Program.cs — Service B&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenIddict&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddValidation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetIssuer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AuthorizationServer:Authority"&lt;/span&gt;&lt;span class="p"&gt;]!);&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAudiences&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AuthorizationServer:ClientId"&lt;/span&gt;&lt;span class="p"&gt;]!);&lt;/span&gt;

        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseIntrospection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetClientId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AuthorizationServer:ClientId"&lt;/span&gt;&lt;span class="p"&gt;]!)&lt;/span&gt;
               &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetClientSecret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AuthorizationServer:ClientSecret"&lt;/span&gt;&lt;span class="p"&gt;]!);&lt;/span&gt;

        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSystemNetHttp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAspNetCore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OpenIddictValidationAspNetCoreDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationScheme&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthentication&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"protected data"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  When M2M authentication is the right choice
&lt;/h3&gt;

&lt;p&gt;The Client Credentials flow is the right tool whenever there is no human in the loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Microservices&lt;/strong&gt; — service-to-service calls in a distributed architecture where each service needs to prove its identity to the others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background jobs and scheduled tasks&lt;/strong&gt; — a worker process that calls an API periodically, without any user context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data pipelines and integrations&lt;/strong&gt; — a system that ingests or exports data from an API on a fixed schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal tooling&lt;/strong&gt; — admin scripts or automation that need authenticated access to internal APIs.
If a user is involved at any point in the flow — even indirectly — the Authorization Code flow with PKCE is more appropriate. The Client Credentials flow is specifically for service identities, not user identities.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The authorization server side
&lt;/h3&gt;

&lt;p&gt;The code above assumes a running authorization server that supports the Client Credentials flow. I've been building &lt;a href="https://identitysuite.net" rel="noopener noreferrer"&gt;IdentitySuite&lt;/a&gt;, a self-hosted OIDC/OAuth 2.0 identity server for ASP.NET Core, and it supports this out of the box — client registration, scope management, and token issuance are all handled through the admin UI, with no additional configuration required on the server side. The Client Credentials flow is enabled per client, so you have full control over which services are allowed to authenticate this way.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>microservices</category>
    </item>
    <item>
      <title>What are JWT tokens and why your app needs them</title>
      <dc:creator>Stefano Pinato</dc:creator>
      <pubDate>Mon, 07 Sep 2026 18:01:08 +0000</pubDate>
      <link>https://dev.to/_stefano_pinato/what-are-jwt-tokens-and-why-your-app-needs-them-3pjc</link>
      <guid>https://dev.to/_stefano_pinato/what-are-jwt-tokens-and-why-your-app-needs-them-3pjc</guid>
      <description>&lt;h2&gt;
  
  
  What they are, how they work, and why your app needs them
&lt;/h2&gt;

&lt;p&gt;You log into an application. A few seconds later, you are in — and the app knows who you are, what you are allowed to do, and keeps knowing it for the rest of your session. But how? The server did not ask you to log in again on the next page. It did not look you up in a database on every click. Something is carrying your identity around, and in most modern applications, that something is a &lt;strong&gt;JWT token&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This article explains what JWT tokens are, what they contain, and why they have become the standard mechanism for authentication in modern applications.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem JWT tokens solve
&lt;/h3&gt;

&lt;p&gt;Before tokens, the most common approach was &lt;strong&gt;server-side sessions&lt;/strong&gt;. When you logged in, the server created a session record in memory or in a database, assigned it an ID, and sent that ID to your browser as a cookie. On every subsequent request, the browser sent the cookie back, the server looked up the session, found your user data, and proceeded.&lt;/p&gt;

&lt;p&gt;This works — but it has limitations. The server has to store and look up session data on every single request. If you have multiple servers behind a load balancer, they all need access to the same session store. If you are building a mobile app or a third-party API integration, cookies are awkward to work with. As applications became more distributed, sessions became a bottleneck.&lt;/p&gt;

&lt;p&gt;The idea behind JWT tokens is different: instead of storing your identity on the server and handing you a key to retrieve it, &lt;strong&gt;the server gives you the identity data directly&lt;/strong&gt; — packaged in a way that cannot be tampered with.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a JWT
&lt;/h3&gt;

&lt;p&gt;JWT stands for &lt;strong&gt;JSON Web Token&lt;/strong&gt;. It is a compact, self-contained string that carries information about a user — or about the permissions granted to an application — in a format that any system can read and verify independently.&lt;/p&gt;

&lt;p&gt;A JWT looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;eyJhbGciOiJSUzI&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;NiIsInR&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;cCI&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="err"&gt;IkpXVCJ&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="err"&gt;.eyJzdWIiOiIxMjM&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="err"&gt;NTYiLCJuYW&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;lIjoiSmFuZSBEb&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;UiLCJlbWFpbCI&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="err"&gt;ImphbmVAZXhhbXBsZS&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;jb&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="err"&gt;iLCJyb&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;xlIjoiYWRtaW&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="err"&gt;iLCJleHAiOjE&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;MTcwMDAwMDB&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="err"&gt;.SflKxwRJSMeKKF&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;QT&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="err"&gt;fwpMeJf&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="err"&gt;POk&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="err"&gt;yJV_adQssw&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is not encrypted by default — it is just encoded. Which means you can decode it and read what is inside. The three parts separated by dots are the &lt;strong&gt;header&lt;/strong&gt;, the &lt;strong&gt;payload&lt;/strong&gt;, and the &lt;strong&gt;signature&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a JWT actually contains
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;header&lt;/strong&gt; describes the token itself — specifically, which algorithm was used to sign it. This tells the receiver how to verify the signature.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;payload&lt;/strong&gt; is where the actual information lives. It is a JSON object containing a set of &lt;em&gt;claims&lt;/em&gt; — statements about the user or the context of the token. A typical payload looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Jane Doe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jane@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"admin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1717000000&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each field is a claim. Some are standardized — &lt;code&gt;sub&lt;/code&gt; is the subject (the user identifier), &lt;code&gt;exp&lt;/code&gt; is the expiration timestamp. Others like &lt;code&gt;role&lt;/code&gt; are custom claims that your application defines based on its own needs.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;signature&lt;/strong&gt; is what makes the token trustworthy. It is generated by the authorization server using a private key, and it covers both the header and the payload. If anyone modifies even a single character of the token, the signature becomes invalid.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Encoded, not encrypted&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The payload of a standard JWT is Base64-encoded, which means anyone who has the token can read its contents. Never put sensitive information — passwords, credit card numbers, personal data — inside a JWT payload. The signature guarantees integrity, not confidentiality.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  How the signature makes it trustworthy
&lt;/h3&gt;

&lt;p&gt;When your authorization server issues a JWT, it signs it with a private key that only the server holds. When an API receives that token, it verifies the signature using the corresponding public key — which it can obtain from the authorization server's discovery endpoint.&lt;/p&gt;

&lt;p&gt;If the signature is valid, the API knows two things: the token was genuinely issued by the authorization server, and its contents have not been modified since. No database lookup required — the token is self-contained and self-verifiable.&lt;/p&gt;

&lt;p&gt;This is why JWT tokens work so well in distributed systems. Any service that has the public key can independently verify any token, without coordinating with a central session store.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why your app needs this
&lt;/h3&gt;

&lt;p&gt;JWT tokens solve several practical problems at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stateless authentication&lt;/strong&gt; — the server does not need to store session data. Every request is independently verifiable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scalability&lt;/strong&gt; — any instance of your API can validate any token, with no shared state between servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-domain access&lt;/strong&gt; — tokens travel in HTTP headers, making them easy to use across different domains and services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rich context&lt;/strong&gt; — a single token can carry roles, permissions, and user data, reducing the need for additional lookups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standard interoperability&lt;/strong&gt; — JWT is an open standard. Any framework, in any language, knows how to handle them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The flip side
&lt;/h3&gt;

&lt;p&gt;JWT tokens are not without trade-offs, and understanding them is important before relying on them in production.&lt;/p&gt;

&lt;p&gt;The most significant limitation is &lt;strong&gt;revocation&lt;/strong&gt;. Because a JWT is self-contained and stateless, there is no built-in way to invalidate it before it expires. If a token is stolen, or a user's permissions change, the token remains valid until its &lt;code&gt;exp&lt;/code&gt; timestamp is reached. This makes token lifetime a critical security parameter — access tokens should be short-lived, typically between 5 and 15 minutes.&lt;/p&gt;

&lt;p&gt;There are approaches to handle revocation — reference tokens, token introspection, blocklists — but they all introduce some degree of server-side state, partially trading away the stateless advantage. The right choice depends on your application's security requirements.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Short lifetimes are not optional&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A JWT that is valid for 24 hours is a significant security risk. If it is intercepted, an attacker has a full day to use it. Keep access token lifetimes short, and use refresh tokens to maintain sessions without compromising security.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Managing JWT tokens without the complexity
&lt;/h3&gt;

&lt;p&gt;Handling JWT tokens correctly — signing certificates, token lifetimes, revocation strategies, claims configuration — requires careful decisions at every step. I've been building &lt;a href="https://identitysuite.net" rel="noopener noreferrer"&gt;IdentitySuite&lt;/a&gt;, a self-hosted OIDC/OAuth 2.0 identity server for ASP.NET Core built on OpenIddict, that handles all of this out of the box, with secure defaults and a full admin UI to manage every aspect of your token configuration without writing a line of code.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>security</category>
      <category>webdev</category>
      <category>authentication</category>
    </item>
    <item>
      <title>OpenIddict and IdentitySuite — secure configuration without the complexity</title>
      <dc:creator>Stefano Pinato</dc:creator>
      <pubDate>Mon, 27 Jul 2026 18:13:50 +0000</pubDate>
      <link>https://dev.to/_stefano_pinato/openiddict-and-identitysuite-secure-configuration-without-the-complexity-17jh</link>
      <guid>https://dev.to/_stefano_pinato/openiddict-and-identitysuite-secure-configuration-without-the-complexity-17jh</guid>
      <description>&lt;p&gt;Closing out the series — &lt;a href="https://dev.to/_stefano_pinato/what-is-openiddict-the-net-framework-developers-choose-for-oauth-20-and-openid-connect"&gt;part 1&lt;/a&gt; covered what OpenIddict is, &lt;a href="https://dev.to/_stefano_pinato/the-pitfalls-of-configuring-openiddict-from-scratch-what-can-go-wrong"&gt;part 2&lt;/a&gt; covered what commonly goes wrong. This time: how I ended up solving each of those pitfalls in IdentitySuite, the identity server I've been building on top of OpenIddict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secure configuration without the complexity
&lt;/h2&gt;

&lt;p&gt;In the previous article, we looked at the most common mistakes developers make when configuring OpenIddict from scratch — development certificates in production, token lifetimes left at defaults, missing PKCE enforcement, loose redirect URI validation, and no revocation strategy. Each one is a real vulnerability, not just a best practice to follow when convenient.&lt;/p&gt;

&lt;p&gt;In this article, I'll go through how IdentitySuite addresses each of those points — and in most cases, that answer requires no code at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Certificates — persistent, managed, monitored
&lt;/h3&gt;

&lt;p&gt;The pitfall: development certificates are ephemeral. Every restart invalidates all active tokens, and they are not suitable for production under any circumstances.&lt;/p&gt;

&lt;p&gt;IdentitySuite handles certificate management as a first-class concern, with two distinct modes depending on your deployment requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Built-in certificates&lt;/strong&gt; are the default mode. IdentitySuite generates RSA 4096 self-signed certificates automatically and manages their entire lifecycle — creation, storage, and rotation — without any manual intervention. When a certificate approaches expiry, the system rotates it automatically and keeps the previous certificate available for long enough to cover all tokens that were issued with it. No expired token is invalidated prematurely.&lt;/p&gt;

&lt;p&gt;While self-signed certificates are not the recommended choice for high-compliance environments, they are a legitimate option for production — RSA 4096 provides strong cryptographic guarantees, and the fully automated lifecycle removes the operational risk of forgetting a manual rotation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom certificates&lt;/strong&gt; allow you to load your own certificates — from the filesystem or from the certificate store on systems that support it. This is the recommended path when your security policy requires certificates issued by a trusted CA or managed through your organization's PKI. In this mode, rotation is manual: you upload the new certificate when needed, and IdentitySuite handles the transition, keeping the previous certificate active until all tokens signed with it have expired.&lt;/p&gt;

&lt;p&gt;In both modes, the Dashboard displays the expiry status of signing and encryption certificates in real time, with visual warnings when rotation is approaching. No scripts to write, no external monitoring to set up — the system surfaces the information where it is needed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;What this means in practice&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Certificates survive application restarts and are consistent across all instances of your deployment. In a load-balanced environment, every node uses the same certificate — tokens issued by one instance are valid on all others. And regardless of the rotation mode, no active token is ever invalidated early due to a certificate change.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Token lifetimes — explicit, configurable, visible
&lt;/h3&gt;

&lt;p&gt;The pitfall: leaving token lifetimes at framework defaults means access tokens that are valid far longer than necessary, with no easy way to adjust them without touching code.&lt;/p&gt;

&lt;p&gt;In IdentitySuite, every token lifetime is configurable from the admin UI. Access tokens, refresh tokens, authorization codes, identity tokens, device codes — each has its own field, with the current value always visible.&lt;/p&gt;

&lt;p&gt;The defaults IdentitySuite ships with are deliberately conservative — short access token lifetimes, reasonable refresh token windows. You can adjust them at any time without a redeployment. Since token lifetimes are read at startup, changes take effect after the next process recycle — which in most hosting environments means a few seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  PKCE — enforced at the server level
&lt;/h3&gt;

&lt;p&gt;The pitfall: OpenIddict supports PKCE but does not enforce it by default. A single missing method call leaves public clients vulnerable to authorization code interception.&lt;/p&gt;

&lt;p&gt;IdentitySuite enforces PKCE at the server level for all authorization code flows by default — the configuration is applied globally and consistently, regardless of how individual clients are registered. For most deployments, this is the right choice and requires no additional setup.&lt;/p&gt;

&lt;p&gt;If your scenario requires more flexibility — for example, when integrating legacy clients that do not support PKCE — the enforcement can be disabled through a configuration option. The default, however, is always the secure one: PKCE on, protection active from the first request.&lt;/p&gt;

&lt;p&gt;The same requirement is visible and verifiable at the individual client level, so you always know exactly what security constraints apply to each registered application — without having to cross-reference multiple configuration files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redirect URIs — exact matching, no exceptions
&lt;/h3&gt;

&lt;p&gt;The pitfall: registering redirect URIs that are too broad, or accepting wildcards and pattern matching, creates open redirect vulnerabilities that can be exploited to capture authorization codes.&lt;/p&gt;

&lt;p&gt;In IdentitySuite, redirect URIs are registered per client through the admin interface. The validation model enforces exact URI matching — what you register is exactly what will be accepted, nothing more. There are no wildcards, no path prefixes, no pattern matching.&lt;/p&gt;

&lt;p&gt;Adding or removing redirect URIs requires no code changes and no redeployment. Client configurations are stored in the database and take effect immediately, which is particularly useful during onboarding of new client applications.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scopes and audiences — consistent by design
&lt;/h3&gt;

&lt;p&gt;The pitfall: in a raw OpenIddict setup, scopes and audiences must be configured consistently across three separate places — the server registration, the API resource definitions, and each client registration. A mismatch at any point produces tokens that either fail validation or grant access they should not.&lt;/p&gt;

&lt;p&gt;IdentitySuite structures this as a clear hierarchy. At the server level, you define which scopes are globally available. Each API scope is then associated with one or more API resources — the actual services that will validate tokens. Finally, each client declares exactly which identity scopes and API scopes it is allowed to request.&lt;/p&gt;

&lt;p&gt;Because every level references the one above it, the configuration is always coherent end-to-end. There is no way to assign a scope to a client that has not been defined at the server level, and no way for a token to reach an API resource it was not intended for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Why this matters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Scope and audience misconfiguration is one of the most common sources of frustration when setting up an authorization server from scratch. IdentitySuite resolves it structurally — the hierarchy enforces consistency, so there is no configuration to get wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Token revocation — available when you need it most
&lt;/h3&gt;

&lt;p&gt;The pitfall: skipping token revocation means that in the event of a security incident, compromised tokens remain valid until they naturally expire — with no way to stop them.&lt;/p&gt;

&lt;p&gt;IdentitySuite includes a token revocation system built directly into the Dashboard. A single action revokes all active tokens across all clients — useful in emergency scenarios such as a suspected credential leak or a Data Protection key rotation.&lt;/p&gt;

&lt;p&gt;Per-client revocation is also available, allowing you to terminate all sessions for a specific application without affecting others. Both actions are audited, so there is always a record of when revocation happened and who triggered it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Emergency revocation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you revoke all tokens alongside a Data Protection key rotation, IdentitySuite handles the post-revocation session cleanup automatically — including the antiforgery cookie invalidation that would otherwise leave the admin UI in a broken state.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  What you actually write
&lt;/h3&gt;

&lt;p&gt;To put this in perspective: getting OpenIddict to a production-ready state from scratch requires writing and maintaining a significant amount of security-critical configuration code. Certificates, lifetimes, PKCE enforcement, redirect URI validation, scope definitions, revocation endpoints — each one is a decision point where a mistake has consequences.&lt;/p&gt;

&lt;p&gt;With IdentitySuite, your &lt;code&gt;Program.cs&lt;/code&gt; looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddIdentitySuiteServices&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetupIdentitySuiteDbAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseIdentitySuiteServices&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything covered in this series — certificates, token lifetimes, PKCE, redirect URIs, scopes, audiences, revocation — is handled internally by those three method calls. The design philosophy is deliberate: ship with secure, production-ready defaults so you can get a working, secure environment up and running immediately, without writing a single line of security-critical code.&lt;/p&gt;

&lt;p&gt;At the same time, the system remains highly configurable. All the settings discussed in this article are manageable through the admin UI at runtime — no code changes, no configuration file edits required. For teams that need deeper customization, a full set of options is available to tailor every aspect of the system to their specific requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up the series
&lt;/h2&gt;

&lt;p&gt;Over three articles, we've covered what OpenIddict is, where its configuration complexity lies, and how IdentitySuite addresses each of those points in a structured and auditable way.&lt;/p&gt;

&lt;p&gt;If you're building a .NET application that needs OAuth 2.0 or OpenID Connect, and you want the reliability of OpenIddict without the configuration surface, &lt;a href="https://identitysuite.net" rel="noopener noreferrer"&gt;IdentitySuite&lt;/a&gt; is worth a look. It's free to run forever for small deployments (1 client / 100 users), with paid plans for larger setups.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about any of the decisions covered in this series, OpenIddict in general, or IdentitySuite specifically.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>security</category>
    </item>
    <item>
      <title>The pitfalls of configuring OpenIddict from scratch — what can go wrong</title>
      <dc:creator>Stefano Pinato</dc:creator>
      <pubDate>Wed, 22 Jul 2026 21:21:09 +0000</pubDate>
      <link>https://dev.to/_stefano_pinato/the-pitfalls-of-configuring-openiddict-from-scratch-what-can-go-wrong-11d5</link>
      <guid>https://dev.to/_stefano_pinato/the-pitfalls-of-configuring-openiddict-from-scratch-what-can-go-wrong-11d5</guid>
      <description>&lt;p&gt;Part 2 of the series — in &lt;a href="https://dev.to/_stefano_pinato/what-is-openiddict-the-net-framework-developers-choose-for-oauth-20-and-openid-connect"&gt;part 1&lt;/a&gt; we covered what OpenIddict is. This time: what goes wrong when configuring it, and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can go wrong — and why it matters more than you think
&lt;/h2&gt;

&lt;p&gt;In the previous article, we established what OpenIddict is and why it has become the go-to choice for .NET developers who need a standards-compliant authorization server. The framework is solid, well-maintained, and flexible.&lt;/p&gt;

&lt;p&gt;That flexibility, however, comes at a cost. OpenIddict does not make decisions for you — it gives you the tools and expects you to use them correctly. And in the security domain, &lt;strong&gt;incorrect configuration is not just a bug: it is a vulnerability&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This article covers the most common mistakes developers make when setting up OpenIddict from scratch, and what the actual consequences are when those mistakes reach production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using development certificates in production
&lt;/h3&gt;

&lt;p&gt;OpenIddict makes it very easy to get started locally. Two method calls and you have working signing and encryption certificates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDevelopmentEncryptionCertificate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
       &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDevelopmentSigningCertificate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These methods generate ephemeral certificates that live only in memory. Every time the application restarts, new certificates are generated — which means every token signed before the restart becomes invalid immediately. Users get logged out, API calls start failing, and refresh tokens stop working.&lt;/p&gt;

&lt;p&gt;More critically, development certificates are not suitable for production from a security standpoint. They offer no guarantee of key strength, no rotation policy, and no audit trail. They exist purely to reduce friction during development.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Real-world consequence&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Development certificates in production means every application restart invalidates all active sessions. In a load-balanced environment with multiple instances, tokens signed by one instance cannot be validated by another. The result is constant, unpredictable authentication failures.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The correct approach is to provide persistent certificates — loaded from the filesystem or the Windows certificate store — that survive restarts and are shared across all instances of your application. This requires understanding certificate formats, key storage, and rotation strategies before you write a single line of configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Token lifetimes that are too long — or not set at all
&lt;/h3&gt;

&lt;p&gt;OpenIddict allows you to configure the lifetime of every token type: access tokens, refresh tokens, authorization codes, identity tokens. If you do not set these explicitly, the defaults apply — and while the defaults are reasonable, they may not be appropriate for your threat model.&lt;/p&gt;

&lt;p&gt;The problem developers run into most often is access tokens with lifetimes that are far too long. An access token that is valid for 24 hours gives an attacker a 24-hour window to use a stolen token without any way to invalidate it, unless you have also set up token introspection or reference tokens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Setting explicit lifetimes is not optional in production&lt;/span&gt;
&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetAccessTokenLifetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;15&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
       &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetRefreshTokenLifetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;14&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
       &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetAuthorizationCodeLifetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Short access token lifetimes force clients to refresh frequently, which gives you more opportunities to detect and revoke compromised sessions. Authorization codes should be especially short-lived — they are single-use credentials that should expire in seconds, not minutes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The balance to find&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Very short lifetimes improve security but increase the frequency of token refresh operations, which adds load to your server and latency for your users. There is no universal answer — the right values depend on your application's sensitivity and usage patterns.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Not enforcing PKCE for public clients
&lt;/h3&gt;

&lt;p&gt;PKCE (Proof Key for Code Exchange) is a mechanism that prevents authorization code interception attacks. It is mandatory for public clients — browser-based applications and mobile apps that cannot securely store a client secret.&lt;/p&gt;

&lt;p&gt;OpenIddict supports PKCE, but it does not enforce it by default for every client. If you register a public client without requiring PKCE, that client can complete an authorization code flow without it. An attacker who intercepts the authorization code — through a malicious redirect, a browser extension, or a misconfigured redirect URI — can exchange it for tokens directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This enables PKCE support but does not enforce it&lt;/span&gt;
&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AllowAuthorizationCodeFlow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// This makes PKCE mandatory for all authorization code requests&lt;/span&gt;
&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AllowAuthorizationCodeFlow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
       &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireProofKeyForCodeExchange&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference between these two lines is small in code but significant in practice. Per-client enforcement requires additional configuration at the client registration level, which adds another surface for misconfiguration if not handled carefully.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loose redirect URI validation
&lt;/h3&gt;

&lt;p&gt;Redirect URIs are one of the most critical security boundaries in OAuth 2.0. After a user authenticates, the authorization server sends the authorization code to the redirect URI registered for that client. If an attacker can influence where that code is sent, they can capture it.&lt;/p&gt;

&lt;p&gt;OpenIddict performs redirect URI validation, but the quality of that validation depends entirely on how you register your clients. Common mistakes include registering URIs that are too broad, allowing trailing wildcards, or accepting &lt;code&gt;localhost&lt;/code&gt; URIs in production configurations.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;What a misconfigured redirect URI looks like&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Registering &lt;code&gt;https://app.example.com/&lt;/code&gt; as a redirect URI and then accepting any path under that origin is an open redirect vulnerability. An attacker crafts a link that redirects the authorization code to a path they control under your domain. The fix is always exact URI matching — no wildcards, no pattern matching, no exceptions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Scope and audience misconfiguration
&lt;/h3&gt;

&lt;p&gt;Scopes and audiences define what a token is valid for. A token issued with the wrong audience will be accepted by resource servers it was never intended to reach. A token with overly broad scopes gives the bearer more access than they should have.&lt;/p&gt;

&lt;p&gt;Getting this right requires understanding the relationship between client registrations, scope definitions, and resource server validation — three separate concepts that need to be configured consistently. A mismatch at any point in the chain produces tokens that either do not work at all or work in ways you did not intend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Skipping token revocation
&lt;/h3&gt;

&lt;p&gt;By default, JWT access tokens are self-contained and stateless. Once issued, they are valid until they expire — there is no built-in mechanism to invalidate them early. This is fine for short-lived tokens, but becomes a problem when you need to respond to a security incident, a user logs out, or an account is compromised.&lt;/p&gt;

&lt;p&gt;OpenIddict supports reference tokens, which store the actual token payload in the database and issue only an opaque handle to the client. This makes revocation straightforward — you delete the record and the token stops working immediately. But reference tokens require token introspection at every API call, which adds a database lookup to each request.&lt;/p&gt;

&lt;p&gt;Choosing between JWT and reference tokens — and setting up revocation correctly for either — is a decision that has to be made deliberately, based on your application's requirements. Skipping it because it seems complex is a common shortcut with real consequences.&lt;/p&gt;

&lt;h3&gt;
  
  
  The underlying pattern
&lt;/h3&gt;

&lt;p&gt;Looking at these pitfalls together, a pattern emerges: none of them are caused by bugs in OpenIddict. They are all the result of decisions — or the absence of decisions — during configuration. OpenIddict is a general-purpose framework, and general-purpose frameworks cannot make security decisions on your behalf.&lt;/p&gt;

&lt;p&gt;This is not a criticism of the library. It is simply the nature of building something flexible enough to serve a wide range of use cases. The trade-off is that every team using OpenIddict needs to develop enough expertise to configure it correctly — or rely on something that has already made those decisions in a secure and consistent way.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>security</category>
    </item>
    <item>
      <title>Introducing IdentitySuite 3.0</title>
      <dc:creator>Stefano Pinato</dc:creator>
      <pubDate>Tue, 14 Jul 2026 09:43:00 +0000</pubDate>
      <link>https://dev.to/_stefano_pinato/introducing-identitysuite-30-ncl</link>
      <guid>https://dev.to/_stefano_pinato/introducing-identitysuite-30-ncl</guid>
      <description>&lt;p&gt;I've been writing a short series on OpenIddict basics while preparing this release — IdentitySuite 3.0 is our biggest visual and customization update yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's New in IdentitySuite 3.0
&lt;/h2&gt;

&lt;p&gt;IdentitySuite 3.0 is our most visually ambitious release yet. We've reimagined how developers and designers customize authentication and account management pages, introducing a modern Theme Builder with new templates, gradient support, image backgrounds, and a centralized image library shared across each client.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🚀 &lt;strong&gt;Highlight&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New Theme Builder with 4 templates&lt;/li&gt;
&lt;li&gt;Gradient and image backgrounds&lt;/li&gt;
&lt;li&gt;Centralized Image Library per client&lt;/li&gt;
&lt;li&gt;New social login providers: GitHub, Apple, LinkedIn, Instagram&lt;/li&gt;
&lt;li&gt;Live preview with desktop/mobile emulation&lt;/li&gt;
&lt;li&gt;Automatic section highlighting while editing&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  1. A Completely Redesigned Theme Builder
&lt;/h3&gt;

&lt;p&gt;The new Theme Builder introduces a more intuitive and powerful way to customize every visual aspect of your authentication and account management pages. With four new templates and dozens of configurable properties, IdentitySuite 3.0 gives you full control over layout, spacing, typography, backgrounds, and branding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four New Layout Templates&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Centered Form&lt;/strong&gt; — Classic layout with the form centered and the logo above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Form Left&lt;/strong&gt; — Form on the right, brand panel on the left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Form Right&lt;/strong&gt; — Form on the left, brand panel on the right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centered Layout&lt;/strong&gt; — Logo and title above the form, subtitle below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each template unlocks different customization options inside the Theme Builder, such as Page Container, Brand Panel, Card Header, and Card Body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gradient &amp;amp; Image Backgrounds&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Version 3.0 introduces full support for gradient backgrounds and image backgrounds across multiple sections, including Page, Page Container, Brand Panel, and Card Body. This allows you to create visually rich and modern layouts with minimal effort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live Preview with Automatic Highlighting&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The preview area now includes desktop and mobile emulation, and automatically highlights the section you are editing with a red outline. This makes it easier to understand exactly which part of the page is being modified.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Pro Tip — Default Values&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Default Values accordion allows you to define global parameters such as background, color, border-radius, border-width, and typography. When you click Apply, these values are automatically propagated to all compatible elements, instantly updating the entire theme preview. You can then refine individual components using their specific settings for precise visual control.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2. New Social Login Providers
&lt;/h3&gt;

&lt;p&gt;IdentitySuite 3.0 expands its authentication capabilities with four new social login providers: &lt;strong&gt;GitHub&lt;/strong&gt;, &lt;strong&gt;Apple&lt;/strong&gt;, &lt;strong&gt;LinkedIn&lt;/strong&gt;, and &lt;strong&gt;Instagram&lt;/strong&gt;. Combined with the existing Google, Facebook, and Microsoft providers, this update offers more flexibility and a smoother onboarding experience for end users.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Centralized Image Library
&lt;/h3&gt;

&lt;p&gt;Each client now includes a centralized Image Library used for logos, background images, and brand assets. Images can be uploaded, reused, or removed directly from the Appearance panel or the Email Builder, ensuring consistent branding across all pages and email templates.&lt;/p&gt;

&lt;p&gt;The Theme Builder integrates seamlessly with the Image Library, allowing you to select logos and background images while customizing the Brand Panel or Page Container.&lt;/p&gt;

&lt;p&gt;For a complete overview of the new Theme Builder, including templates, layout controls, gradient backgrounds, and image management, check out the &lt;a href="https://identitysuite.net/documentation/ThemeBuilder" rel="noopener noreferrer"&gt;Theme Builder documentation&lt;/a&gt;, and browse real examples in the new &lt;a href="https://identitysuite.net/identity-gallery" rel="noopener noreferrer"&gt;IdentitySuite UI Gallery&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Improved Branding Controls
&lt;/h3&gt;

&lt;p&gt;Brand Title, Brand Subtitle, and Logo Source have been enhanced with new typography, spacing, alignment, and shadow options. Logo rendering now supports object-fit, position, margin, and custom sizing, giving you complete control over brand presentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Enhanced Account Management Customization
&lt;/h3&gt;

&lt;p&gt;Account pages now support deeper customization through dedicated sections for Page, Card, Card Header, Card Body, Nav Menu, and Nav Button. Backgrounds, spacing, typography, and hover/selected states can be configured independently for a polished and consistent user experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading from 2.x
&lt;/h2&gt;

&lt;p&gt;Upgrading to 3.0 is straightforward: update your NuGet packages (&lt;code&gt;IdentitySuite&lt;/code&gt; and &lt;code&gt;IdentitySuite.EntityFrameworkCore&lt;/code&gt;) to version 3.0.0, enable &lt;strong&gt;Apply migrations&lt;/strong&gt; in your Database settings, and start your application — the required migrations are applied automatically on startup, no manual database work needed.&lt;/p&gt;

&lt;p&gt;Existing client themes remain fully compatible with the new theming system, so no action is required after upgrading. If you'd like to adopt the new Theme Builder defaults on an existing client, you can reset the theme at any time from Configuration → Theme Editor.&lt;/p&gt;

&lt;p&gt;If your project overrides IdentitySuite's controller endpoint delegates, note that their signatures have changed in 3.0 — see the &lt;a href="https://identitysuite.net/documentation/migrations" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; for details.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ready to Explore IdentitySuite 3.0?
&lt;/h2&gt;

&lt;p&gt;The fastest way to get started is through our example projects repository — ready-to-run sample applications and pre-built client configurations for several common scenarios, so you can have a working setup without writing configuration from scratch:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/spin973/IdentitySuite" rel="noopener noreferrer"&gt;https://github.com/spin973/IdentitySuite&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;IdentitySuite's core identity server — OIDC/OAuth 2.0 flows, user and client management, full configuration — is free with no time limit for deployments up to 1 client and 100 users. Premium features like the Theme Builder shown in this article, along with the Dashboard and Email Template Builder, are available as a full 30-day preview on the Free plan, so you can try them before deciding whether to upgrade. Paid plans start at €349/year, with a 14-day no-questions-asked refund.&lt;/p&gt;

&lt;p&gt;Full documentation: &lt;a href="https://identitysuite.net/documentation" rel="noopener noreferrer"&gt;identitysuite.net/documentation&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>release</category>
    </item>
    <item>
      <title>What is OpenIddict: the .NET framework developers choose for OAuth 2.0 and OpenID Connect</title>
      <dc:creator>Stefano Pinato</dc:creator>
      <pubDate>Fri, 10 Jul 2026 06:35:09 +0000</pubDate>
      <link>https://dev.to/_stefano_pinato/what-is-openiddict-the-net-framework-developers-choose-for-oauth-20-and-openid-connect-47io</link>
      <guid>https://dev.to/_stefano_pinato/what-is-openiddict-the-net-framework-developers-choose-for-oauth-20-and-openid-connect-47io</guid>
      <description>&lt;p&gt;I've been writing a short series on OpenIddict basics while preparing IdentitySuite's v3.0 release — here's part 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  The .NET framework developers choose for OAuth 2.0 and OpenID Connect
&lt;/h2&gt;

&lt;p&gt;At some point in every .NET developer's journey, the same question comes up: &lt;strong&gt;how do I handle authentication properly?&lt;/strong&gt; Not just a login form — but token issuance, authorization flows, secure API access, and all the moving parts that modern applications require.&lt;/p&gt;

&lt;p&gt;If you have been researching this topic, you have probably come across &lt;strong&gt;OpenIddict&lt;/strong&gt;. This article explains what it is, what it actually does, and why so many developers in the .NET ecosystem rely on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is OpenIddict?
&lt;/h3&gt;

&lt;p&gt;OpenIddict is an &lt;strong&gt;open-source framework for ASP.NET Core&lt;/strong&gt; that allows you to add a fully compliant OAuth 2.0 and OpenID Connect authorization server to your application. In plain terms: it is the component responsible for issuing and validating the tokens that your applications and APIs use to authenticate users and authorize requests.&lt;/p&gt;

&lt;p&gt;It is not a hosted service, a SaaS product, or a cloud platform. It is a library you add to your .NET project, giving you complete control over where it runs and how it is configured.&lt;/p&gt;

&lt;p&gt;OpenIddict is maintained by &lt;a href="https://documentation.openiddict.com/" rel="noopener noreferrer"&gt;Kévin Chalet&lt;/a&gt; and has been actively developed for years. It is &lt;strong&gt;certified by the OpenID Foundation&lt;/strong&gt;, which means it has passed formal conformance tests against the OpenID Connect specification — not a minor detail when security is involved.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;OpenID Foundation Certification&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Certification means that OpenIddict's implementation has been independently verified to comply with the OpenID Connect specification. For developers, this translates to one less thing to worry about: the protocol behavior is correct by design.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  How it fits into the .NET ecosystem
&lt;/h3&gt;

&lt;p&gt;One of the reasons OpenIddict is popular in the .NET world is that it does not try to replace what already exists — it complements it.&lt;/p&gt;

&lt;p&gt;OpenIddict integrates naturally with &lt;strong&gt;ASP.NET Core Identity&lt;/strong&gt;, Microsoft's user management system. Identity handles the user store: creating accounts, hashing passwords, managing roles and claims. OpenIddict sits on top of it and handles the protocol layer: deciding when to issue a token, what claims to include, and how to respond to authorization requests.&lt;/p&gt;

&lt;p&gt;For persistence, OpenIddict uses &lt;strong&gt;Entity Framework Core&lt;/strong&gt;, which means it works with the same database you are probably already using — SQL Server, PostgreSQL, or MySQL. Applications, tokens, and authorizations are stored as regular EF Core entities, queryable and manageable like any other data in your system.&lt;/p&gt;

&lt;p&gt;This tight integration with the existing .NET stack is intentional. Rather than introducing new abstractions and paradigms, OpenIddict speaks the same language as the rest of your application.&lt;/p&gt;

&lt;h3&gt;
  
  
  What OpenIddict actually handles
&lt;/h3&gt;

&lt;p&gt;To understand what OpenIddict does, it helps to look at what an authorization server is responsible for. When a user logs into your application, or when one service needs to call another securely, a series of standardized exchanges takes place. OpenIddict implements all of them.&lt;/p&gt;

&lt;p&gt;Concretely, it exposes and manages the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authorization endpoint&lt;/strong&gt; — where users are redirected to authenticate and consent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token endpoint&lt;/strong&gt; — where authorization codes or credentials are exchanged for tokens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UserInfo endpoint&lt;/strong&gt; — where applications retrieve profile information about the authenticated user&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Introspection endpoint&lt;/strong&gt; — where resource servers validate tokens without parsing them directly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation endpoint&lt;/strong&gt; — where tokens can be explicitly invalidated&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discovery endpoint&lt;/strong&gt; — the &lt;code&gt;/.well-known/openid-configuration&lt;/code&gt; document that client applications use to configure themselves automatically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also supports all the major OAuth 2.0 grant types: Authorization Code (with and without PKCE), Client Credentials, Refresh Token, and Device Authorization.&lt;/p&gt;

&lt;p&gt;To give you a concrete sense of what this looks like in code, here is a minimal example of how OpenIddict is registered in an ASP.NET Core application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenIddict&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddCore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseEntityFrameworkCore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UseDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApplicationDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetAuthorizationEndpointUris&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/connect/authorize"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
               &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetTokenEndpointUris&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/connect/token"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
               &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetUserinfoEndpointUris&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/connect/userinfo"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AllowAuthorizationCodeFlow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AllowRefreshTokenFlow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireProofKeyForCodeExchange&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDevelopmentEncryptionCertificate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDevelopmentSigningCertificate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAspNetCore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnableAuthorizationEndpointPassthrough&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnableTokenEndpointPassthrough&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddValidation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseLocalServer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAspNetCore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a working starting point — but it is also just the beginning. A production-ready configuration requires handling certificates properly, configuring token lifetimes, securing endpoints, registering client applications, and more. The flexibility of OpenIddict means all of this is possible, but it also means the responsibility falls on the developer to get it right.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Flexibility comes with responsibility&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;OpenIddict gives you all the building blocks. How you assemble them — and whether the result is secure and standards-compliant — depends on the decisions you make during configuration. This is not a criticism of the library; it is simply the nature of a general-purpose framework.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Why developers choose it
&lt;/h3&gt;

&lt;p&gt;Several factors explain why OpenIddict has established itself as a reference choice in the .NET space:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open source with a permissive license.&lt;/strong&gt; OpenIddict is released under the Apache 2.0 license, which means you can use it freely in commercial projects without licensing fees or usage restrictions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Active and transparent development.&lt;/strong&gt; The project is actively maintained, with a public roadmap, regular releases, and a responsive issue tracker. When a security vulnerability is discovered or a new specification is published, updates follow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Standards compliance, verified.&lt;/strong&gt; As mentioned, OpenIddict holds official OpenID Connect certification. This is not self-declared — it is the result of passing a formal test suite administered by the OpenID Foundation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No external dependencies beyond the .NET stack.&lt;/strong&gt; Unlike some alternatives that require specific infrastructure or cloud services, OpenIddict runs wherever .NET runs. On-premises, in Docker, on Azure, on a Raspberry Pi — the deployment model is entirely yours to decide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A strong and growing community.&lt;/strong&gt; Stack Overflow, GitHub Discussions, and various .NET communities have built up a solid body of knowledge around OpenIddict. Finding answers to common configuration questions is increasingly straightforward.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrapping up
&lt;/h3&gt;

&lt;p&gt;OpenIddict is a mature, certified, and well-maintained framework that brings OAuth 2.0 and OpenID Connect to the .NET ecosystem without forcing you outside the tools you already know. It handles the protocol complexity so you can focus on your application — provided you configure it correctly.&lt;/p&gt;

&lt;p&gt;And that last part — configuring it correctly — is where things get interesting. In the &lt;a href="https://identitysuite.net/blog/identitysuite/openiddict-configuration-pitfalls" rel="noopener noreferrer"&gt;next article in this series&lt;/a&gt;, we will look at the most common mistakes developers make when setting up OpenIddict from scratch, and why getting those details wrong can have real security consequences.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>oauth</category>
    </item>
  </channel>
</rss>
