<?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: Jeff Tsai</title>
    <description>The latest articles on DEV Community by Jeff Tsai (@jeff377).</description>
    <link>https://dev.to/jeff377</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%2F4181868%2F2f539f0e-aeec-4ef8-ae80-a4110d0cec24.jpg</url>
      <title>DEV Community: Jeff Tsai</title>
      <link>https://dev.to/jeff377</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeff377"/>
    <language>en</language>
    <item>
      <title>A JSON-RPC Library With No Method Registration: How Does It Decide What Can Be Called?</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:09:53 +0000</pubDate>
      <link>https://dev.to/jeff377/a-json-rpc-library-with-no-method-registration-how-does-it-decide-what-can-be-called-2dcn</link>
      <guid>https://dev.to/jeff377/a-json-rpc-library-with-no-method-registration-how-does-it-decide-what-can-be-called-2dcn</guid>
      <description>&lt;p&gt;JSON-RPC 2.0 is a small specification: the client sends &lt;code&gt;{"jsonrpc":"2.0","method":"...","params":{...},"id":1}&lt;/code&gt;, and the server answers with a &lt;code&gt;result&lt;/code&gt; or an &lt;code&gt;error&lt;/code&gt;. REST makes you decide on routes, HTTP verbs and status codes; JSON-RPC only cares about which method to call and with what parameters. For an API where you own both the front end and the back end, that is pleasant to work with.&lt;/p&gt;

&lt;p&gt;Polhem.JsonRpc is a set of .NET packages for JSON-RPC 2.0: a transport-independent server, an ASP.NET Core endpoint and a client, built on System.Text.Json alone. It started as the API layer of the &lt;a href="https://github.com/polhem-dev/polhem" rel="noopener noreferrer"&gt;Polhem framework&lt;/a&gt;. I have now extracted it into standalone packages that do not depend on the framework, so other projects can reference them directly. The source is on &lt;a href="https://github.com/polhem-dev/polhem-jsonrpc" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; under the MIT license, and the packages are on &lt;a href="https://www.nuget.org/packages?q=Polhem.JsonRpc" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  1️⃣ What it covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Full JSON-RPC 2.0: ordinary calls, notifications (calls that expect no response), and batches (several calls in one message). An id can be a string, an integer or &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A transport-independent server: the dispatcher (the part that finds and runs the method) knows nothing about HTTP. The ASP.NET Core endpoint is a separate package.&lt;/li&gt;
&lt;li&gt;A client: an HTTP transport, batches, notifications, and interceptors that can rewrite the parameters and result of each call.&lt;/li&gt;
&lt;li&gt;Optional payload encryption: the parameters and result go into an envelope that can be compressed and encrypted, and replayed calls can be rejected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is split into several NuGet packages, so an application references only what it needs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc" rel="noopener noreferrer"&gt;Polhem.JsonRpc&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Message types, error codes and the transport abstraction shared by the server and the client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc.Server" rel="noopener noreferrer"&gt;Polhem.JsonRpc.Server&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The dispatcher: method resolution, parameter binding and filters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc.AspNetCore" rel="noopener noreferrer"&gt;Polhem.JsonRpc.AspNetCore&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The ASP.NET Core endpoint, &lt;code&gt;MapJsonRpc&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc.Client" rel="noopener noreferrer"&gt;Polhem.JsonRpc.Client&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The client, &lt;code&gt;JsonRpcConnector&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc.Payload" rel="noopener noreferrer"&gt;Polhem.JsonRpc.Payload&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Optional: the payload envelope, compression, encryption and replay protection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc.Payload.Client" rel="noopener noreferrer"&gt;Polhem.JsonRpc.Payload.Client&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Optional: a client that seals the parameters and opens the result of every call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc.Payload.Server" rel="noopener noreferrer"&gt;Polhem.JsonRpc.Payload.Server&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Optional: opens the envelope and checks for replays on the server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The server references &lt;code&gt;Polhem.JsonRpc.AspNetCore&lt;/code&gt;, and the client references only &lt;code&gt;Polhem.JsonRpc.Client&lt;/code&gt;. The split keeps a client, whether a desktop app or a mobile app, from pulling in ASP.NET Core and the reflection-based dispatcher.&lt;/p&gt;

&lt;p&gt;Native AOT compiles an app to native code at publish time, so nothing can rely on reflection at run time; iOS and WebAssembly both need it. The shared package, the client and the payload packages are marked AOT-compatible, and CI publishes a test program with Native AOT and runs it. The server package finds methods by reflection, so it does not support AOT. The target framework is &lt;code&gt;net10.0&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  2️⃣ Getting started
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ASP.NET Core server (Minimal API)
&lt;/h3&gt;

&lt;p&gt;The server uses ASP.NET Core Minimal APIs (endpoints mapped directly in &lt;code&gt;Program.cs&lt;/code&gt; with &lt;code&gt;MapXxx&lt;/code&gt;, without MVC controllers). &lt;code&gt;MapJsonRpc&lt;/code&gt; creates a POST endpoint at the given path, and every JSON-RPC call comes in through it. &lt;code&gt;AddControllers&lt;/code&gt; is not needed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Program.cs&lt;/code&gt; needs three things: register the object factory, register the JSON-RPC services, and map the endpoint.&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;using&lt;/span&gt; &lt;span class="nn"&gt;Polhem.JsonRpc.AspNetCore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Polhem.JsonRpc.Server&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;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="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IJsonRpcObjectFactory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AppObjectFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;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;AddJsonRpcServer&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="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapJsonRpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api"&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;RunAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddJsonRpcServer&lt;/code&gt; takes two sets of options. The first configures the server itself, such as filters, the method policy, the batch limit and serializer options. The second configures the HTTP endpoint, such as the request body size limit and which HTTP status code an error returns. Leave them out and the defaults apply.&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;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;AddJsonRpcServer&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="n"&gt;MaxBatchSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;50&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="n"&gt;Filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&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;ApiKeyFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// see section 5&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;http&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxRequestBodySize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MapJsonRpc&lt;/code&gt; returns the same thing as any other Minimal API endpoint, so authorization, CORS and rate limiting chain on the usual way, for example &lt;code&gt;app.MapJsonRpc("/api").RequireAuthorization();&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The object factory and the class that handles the calls:&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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppObjectFactory&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IJsonRpcObjectFactory&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;CreateObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;progId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JsonRpcRequestContext&lt;/span&gt; &lt;span class="n"&gt;context&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;progId&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"Calculator"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Calculator&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;null&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Calculator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;AddResponse&lt;/span&gt; &lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AddRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Sum&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;B&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;
  
  
  Client
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;http&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;HttpClient&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;"http://localhost:5080/api"&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;rpc&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;JsonRpcConnector&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;HttpTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&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;added&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;rpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AddResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"Calculator.Add"&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;AddRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is enough to call &lt;code&gt;Calculator.Add&lt;/code&gt;. Note that the server has no &lt;code&gt;AddMethod&lt;/code&gt; or similar registration, and &lt;code&gt;Calculator&lt;/code&gt; carries no attribute.&lt;/p&gt;




&lt;h2&gt;
  
  
  3️⃣ With no registration, which methods can be called?
&lt;/h2&gt;

&lt;p&gt;Skipping registration is convenient, but it raises a worry: if one day I add a public method to the class for internal use, can it be called from the network too? When the server receives &lt;code&gt;Calculator.Add&lt;/code&gt;, it runs the following checks in order.&lt;/p&gt;

&lt;p&gt;Name format. The method name must be &lt;code&gt;ProgId.Action&lt;/code&gt;. A ProgId (program identifier) may contain letters, digits, underscores and hyphens; an action letters, digits and underscores; each at most 64 characters. A malformed name is rejected before any lookup.&lt;/p&gt;

&lt;p&gt;The application decides the object. The ProgId is passed to your implementation of &lt;code&gt;IJsonRpcObjectFactory.CreateObject&lt;/code&gt;, which decides which object to create; returning &lt;code&gt;null&lt;/code&gt; means the ProgId is unknown. The packages keep no registry of names, so which ProgIds exist is entirely up to the factory. After the call, the object is passed to &lt;code&gt;ReleaseObjectAsync&lt;/code&gt; to be released.&lt;/p&gt;

&lt;p&gt;Method signature. An action maps only to a public, non-generic instance method with exactly one parameter. Static methods, property accessors and methods declared by &lt;code&gt;object&lt;/code&gt; do not count. If two or more methods with the same name qualify, the server does not pick one; it treats the method as not found.&lt;/p&gt;

&lt;p&gt;Naming convention. The parameter type must be named &lt;code&gt;{Action}Request&lt;/code&gt;, and the return type (or the result type of &lt;code&gt;Task&amp;lt;T&amp;gt;&lt;/code&gt; or &lt;code&gt;ValueTask&amp;lt;T&amp;gt;&lt;/code&gt;) must be named &lt;code&gt;{Action}Response&lt;/code&gt;. &lt;code&gt;Add&lt;/code&gt; takes an &lt;code&gt;AddRequest&lt;/code&gt; and returns an &lt;code&gt;AddResponse&lt;/code&gt;. This is the default method policy; any public method that does not follow it is treated as nonexistent and answered with &lt;code&gt;-32601 Method not found&lt;/code&gt;. An internal helper method that does not follow this naming cannot be called from outside.&lt;/p&gt;

&lt;p&gt;The rule can be replaced through &lt;code&gt;JsonRpcServerOptions.MethodPolicy&lt;/code&gt;. The Polhem framework replaces it with one that reads its own access-control attribute.&lt;/p&gt;

&lt;p&gt;Policy before filters. The method policy runs before every filter, so filters such as authorization checks or decryption only ever see calls that are allowed to run.&lt;/p&gt;

&lt;p&gt;Only after these checks are the &lt;code&gt;params&lt;/code&gt; bound. &lt;code&gt;params&lt;/code&gt; must be a JSON object, deserialized into the request class with camelCase names by default; missing &lt;code&gt;params&lt;/code&gt; or an array (positional parameters) are answered with &lt;code&gt;-32602 Invalid params&lt;/code&gt;. To return a custom error, a method throws &lt;code&gt;JsonRpcErrorException&lt;/code&gt;; any other exception is answered with &lt;code&gt;-32603 Internal error&lt;/code&gt;, and the exception message is not sent to the client.&lt;/p&gt;




&lt;h2&gt;
  
  
  4️⃣ The client
&lt;/h2&gt;

&lt;p&gt;An error returned by the server becomes a &lt;code&gt;JsonRpcErrorException&lt;/code&gt;, which carries the error code and message:&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;try&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;rpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DivideResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"Calculator.Divide"&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;DivideRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Dividend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Divisor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonRpcErrorException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Error &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&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;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notifications and batches:&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;// The server runs it but does not respond.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Calculator.Log"&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;LogRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Hello"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Two calls sent in the same message.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBatch&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;first&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AddResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"Calculator.Add"&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;AddRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;3&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;second&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AddResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"Calculator.Add"&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;AddRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;B&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;)!.&lt;/span&gt;&lt;span class="n"&gt;Sum&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;)!.&lt;/span&gt;&lt;span class="n"&gt;Sum&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request and response classes usually live in a project shared by both sides, which is how the samples in the repo are arranged.&lt;/p&gt;




&lt;h2&gt;
  
  
  5️⃣ Extension points: filters and interceptors
&lt;/h2&gt;

&lt;p&gt;A server filter wraps every call. It can reject the call, rewrite the parameters before the method runs, and rewrite the result afterwards. For example, checking an API key:&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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiKeyFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;expectedKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IJsonRpcFilter&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ValueTask&lt;/span&gt; &lt;span class="nf"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonRpcRequestContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JsonRpcFilterDelegate&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Api-Key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;key&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;CryptographicOperations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FixedTimeEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedKey&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;JsonRpcErrorException&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="m"&gt;32001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Unauthorized"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&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="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;AddJsonRpcServer&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="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&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;ApiKeyFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To add an HTTP header on the client, use &lt;code&gt;HttpClient&lt;/code&gt;'s own &lt;code&gt;DelegatingHandler&lt;/code&gt;; the packages add no layer of their own. To rewrite the parameters and result of every call, add an &lt;code&gt;IJsonRpcClientInterceptor&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The Polhem framework plugs its sessions and authorization checks into these extension points (the object factory, the method policy and filters). None of that logic is in the packages themselves.&lt;/p&gt;




&lt;h2&gt;
  
  
  6️⃣ Encrypted payloads
&lt;/h2&gt;

&lt;p&gt;HTTPS protects the connection. If the parameters and results also need end-to-end protection, or a call must not run a second time when it is resent, add the optional payload packages.&lt;/p&gt;

&lt;p&gt;They put &lt;code&gt;params&lt;/code&gt; and &lt;code&gt;result&lt;/code&gt; into an envelope with three formats: plain JSON, encoded (serialized, then gzip-compressed), and encrypted (AES-256-CBC with HMAC-SHA256, encrypt-then-MAC). A frame carrying a timestamp and a sequence number can also be put in front of the content, so the server can reject expired or repeated requests; that is the replay protection.&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;// Server: the application's policy decides the keys and which methods must be encrypted or replay-protected.&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;AddJsonRpcServer&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="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UsePayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payloadOptions&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;MyPayloadPolicy&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

&lt;span class="c1"&gt;// Client: called the same way as an ordinary call; each call seals its parameters and opens its result.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rpc&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;PayloadConnector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connector&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;PayloadProcessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payloadOptions&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;PayloadConnectorOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;KeyProvider&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;sessionKey&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;added&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;rpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AddResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"Calculator.Add"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few more security details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The HMAC is compared in constant time and verified before anything is decrypted.&lt;/li&gt;
&lt;li&gt;The HMAC covers the method name and the direction, so an intercepted call cannot be redirected to another method, and a response cannot be sent back as a request.&lt;/li&gt;
&lt;li&gt;A method can require a minimum format; a request in a lower format is rejected before any key is asked for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How keys are negotiated (exchanged after sign-in, provisioned in advance, and so on) is outside the packages and left to the application.&lt;/p&gt;




&lt;h2&gt;
  
  
  7️⃣ How it differs from common libraries
&lt;/h2&gt;

&lt;p&gt;There are quite a few JSON-RPC libraries for .NET; here are two common ones for comparison. The version in each column header is the latest stable release on NuGet as of October 2026, and the details come from that release's documentation and NuGet dependency list. Later releases may differ.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;StreamJsonRpc 2.25.29&lt;/th&gt;
&lt;th&gt;Tochka.JsonRpc 7.4.1&lt;/th&gt;
&lt;th&gt;Polhem.JsonRpc 1.2.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JSON library&lt;/td&gt;
&lt;td&gt;Newtonsoft.Json by default, switchable to System.Text.Json; the package still depends on Newtonsoft.Json and MessagePack&lt;/td&gt;
&lt;td&gt;System.Text.Json&lt;/td&gt;
&lt;td&gt;System.Text.Json, with no other dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport model&lt;/td&gt;
&lt;td&gt;Full duplex over a Stream, WebSocket or Pipe&lt;/td&gt;
&lt;td&gt;HTTP on ASP.NET Core MVC&lt;/td&gt;
&lt;td&gt;HTTP request and response on ASP.NET Core Minimal APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;Yes, with proxies generated from interfaces&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How methods are registered&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AddLocalRpcTarget&lt;/code&gt; adds a target object whose public methods become callable&lt;/td&gt;
&lt;td&gt;Controllers that inherit &lt;code&gt;JsonRpcControllerBase&lt;/code&gt;; each action is a method&lt;/td&gt;
&lt;td&gt;No registration; found by &lt;code&gt;ProgId.Action&lt;/code&gt; and the naming convention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native AOT&lt;/td&gt;
&lt;td&gt;Partially supported, within the limits its documentation lists&lt;/td&gt;
&lt;td&gt;Tied to MVC, not supported&lt;/td&gt;
&lt;td&gt;The shared package, the client and the payload packages support it; the server does not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good fit&lt;/td&gt;
&lt;td&gt;Two-way communication, such as Visual Studio extensions or LSP&lt;/td&gt;
&lt;td&gt;You already use MVC and want to keep writing controllers&lt;/td&gt;
&lt;td&gt;An API where you own both ends, possibly with desktop or mobile clients&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;StreamJsonRpc is the most complete, with cancellation, progress reporting and server-to-client calls, but it is designed for streams and full duplex; turning it into an HTTP request/response API means building the endpoint yourself.&lt;/p&gt;

&lt;p&gt;Tochka.JsonRpc is the closest in scope to Polhem.JsonRpc. It builds on MVC controllers, so MVC filters and model binding work as they are, at the cost of sharing MVC's lack of AOT support.&lt;/p&gt;

&lt;p&gt;Polhem.JsonRpc goes in a different direction: it trades a fixed call shape for "no registration" and "an API that can evolve", as the next section explains.&lt;/p&gt;




&lt;h2&gt;
  
  
  8️⃣ Design trade-offs
&lt;/h2&gt;

&lt;p&gt;The fixed call shape is there so the API can evolve. The &lt;code&gt;ProgId.Action&lt;/code&gt; name, one request class and one response class are a deliberate restriction. When a member is added to a request or response class, a member an older front end does not send binds to its default on the server, and a result member an older front end does not know is simply skipped when read. Old and new front ends call the same method, with no &lt;code&gt;v1&lt;/code&gt; and &lt;code&gt;v2&lt;/code&gt; endpoints to maintain. Positional parameters cannot do this: add, remove or reorder one parameter and every caller has to change.&lt;/p&gt;

&lt;p&gt;Defaults follow the specification. The internal error code is &lt;code&gt;-32603&lt;/code&gt;, a response carries only &lt;code&gt;jsonrpc&lt;/code&gt;, &lt;code&gt;result&lt;/code&gt; or &lt;code&gt;error&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt; is always written, as &lt;code&gt;null&lt;/code&gt; when it cannot be determined. The Polhem framework's original response format differed from the specification in places; when the packages were extracted, it was the framework that changed to follow the specification, not the packages that bent to the old format.&lt;/p&gt;

&lt;p&gt;The reasoning behind each decision is recorded as ADRs (Architecture Decision Records) in the repo's &lt;a href="https://github.com/polhem-dev/polhem-jsonrpc/tree/main/maintainers/adr" rel="noopener noreferrer"&gt;&lt;code&gt;maintainers/adr/&lt;/code&gt;&lt;/a&gt;, and the security design is summarized in &lt;a href="https://github.com/polhem-dev/polhem-jsonrpc/blob/main/docs/security.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/security.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ Wrapping up
&lt;/h2&gt;

&lt;p&gt;Install the server and client packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Polhem.JsonRpc.AspNetCore
dotnet add package Polhem.JsonRpc.Client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo includes &lt;a href="https://github.com/polhem-dev/polhem-jsonrpc/tree/main/samples" rel="noopener noreferrer"&gt;samples&lt;/a&gt;: an ASP.NET Core Minimal API server, a console client (ordinary calls, error handling, notifications and batches), and a server and client using encrypted payloads.&lt;/p&gt;

&lt;p&gt;These packages do convention-based JSON-RPC, not general-purpose JSON-RPC. Protocols whose method names and parameters are defined by someone else, such as the Language Server Protocol, MCP or an Ethereum node's API (names like &lt;code&gt;textDocument/didOpen&lt;/code&gt; and &lt;code&gt;eth_call&lt;/code&gt; with positional parameters), cannot be implemented with them; StreamJsonRpc or the MCP C# SDK fit those better. Nor do they offer a full-duplex connection where the server calls the client: one call is one HTTP request and its response.&lt;/p&gt;

&lt;p&gt;If you own both ends of your API, want JSON-RPC that relies on System.Text.Json alone, or have clients that run as desktop or mobile apps, give it a try. Feedback, issues and pull requests are welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.jsonrpc.org/specification" rel="noopener noreferrer"&gt;JSON-RPC 2.0 Specification&lt;/a&gt;: the full JSON-RPC 2.0 specification&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/microsoft/vs-streamjsonrpc" rel="noopener noreferrer"&gt;StreamJsonRpc&lt;/a&gt;: the choice when you need full-duplex streams&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/tochka-public/Tochka.JsonRpc" rel="noopener noreferrer"&gt;Tochka.JsonRpc&lt;/a&gt;: JSON-RPC built on ASP.NET Core MVC controllers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.microsoft.com/dotnet/core/deploying/native-aot/" rel="noopener noreferrer"&gt;Native AOT deployment&lt;/a&gt;: the limits and compatibility of .NET Native AOT&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/polhem-jsonrpc" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/polhem-jsonrpc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>opensource</category>
    </item>
    <item>
      <title>One OAuth2 Library for Every Kind of .NET App</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Tue, 13 Oct 2026 11:44:12 +0000</pubDate>
      <link>https://dev.to/jeff377/one-oauth2-library-for-every-kind-of-net-app-k3b</link>
      <guid>https://dev.to/jeff377/one-oauth2-library-for-every-kind-of-net-app-k3b</guid>
      <description>&lt;p&gt;Adding "Sign in with Google" to a .NET application looks like a problem that was solved long ago, until you have more than one kind of application. A website uses ASP.NET Core's authentication handlers, a desktop tool needs something that can open a browser and catch the callback, a mobile app does it another way again, and none of these help the old Web Forms site that is still in production.&lt;/p&gt;

&lt;p&gt;I wanted a small library that handles all of these in the same way, so I built &lt;a href="https://github.com/polhem-dev/polhem-oauth2" rel="noopener noreferrer"&gt;Polhem.OAuth2&lt;/a&gt;. It is open source under the MIT license and available on NuGet.&lt;/p&gt;

&lt;p&gt;If you have used Bee.OAuth2, Polhem.OAuth2 is its successor. It keeps the idea of one set of options per provider (the sign-in service, such as Google or LINE), and most of the rest has been rebuilt. Section 6 lists what changed.&lt;/p&gt;




&lt;h2&gt;
  
  
  1️⃣ What it covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Desktop and console apps: opens the system browser, receives the callback on a loopback address (&lt;code&gt;127.0.0.1&lt;/code&gt; or &lt;code&gt;localhost&lt;/code&gt; on the local machine), and uses PKCE. Runs on Windows, macOS and Linux.&lt;/li&gt;
&lt;li&gt;.NET MAUI apps on Android, iOS and Mac Catalyst: signs in through &lt;code&gt;WebAuthenticator&lt;/code&gt; with PKCE, and keeps no client secret in the app. An app with its own ASP.NET Core back end can sign in through that back end instead, so the provider's tokens never reach the device.&lt;/li&gt;
&lt;li&gt;ASP.NET Core: the authorization code flow with PKCE, with the state of each sign-in kept in its own protected cookie.&lt;/li&gt;
&lt;li&gt;Classic ASP.NET: the same flow for Web Forms and MVC applications on &lt;code&gt;System.Web&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Other server frameworks: the core &lt;code&gt;OAuth2Client&lt;/code&gt; runs the flow in two steps and does not depend on any HTTP framework.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PKCE (Proof Key for Code Exchange) is an extension to OAuth2. The client generates a random value when it starts the sign-in and presents it again when it exchanges the code for tokens, so an intercepted authorization code is useless without that value. For desktop and mobile apps, which cannot keep a client secret, it is essential.&lt;/p&gt;

&lt;p&gt;Supported providers: Google, Facebook, LINE, Microsoft Entra ID, Auth0 and Okta. Every provider returns the result in the same shape: the user ID, name and email, plus the access, refresh and ID tokens.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Target frameworks&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.OAuth2" rel="noopener noreferrer"&gt;Polhem.OAuth2&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;netstandard2.0, net10.0&lt;/td&gt;
&lt;td&gt;The providers; desktop, console and .NET MAUI apps; other server frameworks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.OAuth2.AspNetCore" rel="noopener noreferrer"&gt;Polhem.OAuth2.AspNetCore&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;net10.0&lt;/td&gt;
&lt;td&gt;ASP.NET Core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.OAuth2.AspNet" rel="noopener noreferrer"&gt;Polhem.OAuth2.AspNet&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;net472&lt;/td&gt;
&lt;td&gt;ASP.NET Web Forms and MVC on &lt;code&gt;System.Web&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  2️⃣ Desktop or console apps
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LoopbackOAuth2Client&lt;/code&gt; listens on the redirect URI, opens the sign-in page in the default browser, waits for the provider to redirect back, and exchanges the authorization code:&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;using&lt;/span&gt; &lt;span class="nn"&gt;Polhem.OAuth2&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;options&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;GoogleOAuth2Options&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ClientId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"your-client-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ClientSecret&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"your-client-secret"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RedirectUri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"http://127.0.0.1:0/callback"&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="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;LoopbackOAuth2Client&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="n"&gt;AuthorizationResult&lt;/span&gt; &lt;span class="n"&gt;result&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;SignInAsync&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccess&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserId&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserName&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port &lt;code&gt;0&lt;/code&gt; picks a free port for each sign-in. This only works with providers that accept any loopback port, such as Google. For other providers, use the port you registered.&lt;/p&gt;




&lt;h2&gt;
  
  
  3️⃣ ASP.NET Core apps
&lt;/h2&gt;

&lt;p&gt;Register the client at startup:&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;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;AddOAuth2Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Google"&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;GoogleOAuth2Options&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ClientId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"your-client-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ClientSecret&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"your-client-secret"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RedirectUri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://localhost:7032/auth/callback"&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then start and complete the sign-in with two actions:&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;AuthController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OAuth2Manager&lt;/span&gt; &lt;span class="n"&gt;oauth2Manager&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ControllerBase&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/auth/login"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;IActionResult&lt;/span&gt; &lt;span class="nf"&gt;Login&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oauth2Manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAuthorizationUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Google"&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;HttpGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/auth/callback"&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="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Callback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;AuthorizationResult&lt;/span&gt; &lt;span class="n"&gt;result&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;oauth2Manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CompleteAuthorizationAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HttpContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestAborted&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccess&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Hello, &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserName&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="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"The sign-in failed: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&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="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;The state, PKCE code verifier and redirect URI of each sign-in are kept in their own cookie, encrypted with ASP.NET Core Data Protection. So no session is needed, sign-ins started in several tabs at once do not overwrite each other, and several servers can work together as long as they share the Data Protection keys.&lt;/p&gt;




&lt;h2&gt;
  
  
  4️⃣ .NET MAUI apps
&lt;/h2&gt;

&lt;p&gt;Anything inside a mobile app can be extracted, so it cannot keep a client secret. &lt;code&gt;AppOAuth2Client&lt;/code&gt; therefore always uses PKCE and never sends a client secret. You pass it a function that opens the browser, which is usually &lt;code&gt;WebAuthenticator&lt;/code&gt;:&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;options&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;Auth0OAuth2Options&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Domain&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"your-tenant.auth0.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ClientId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"your-native-client-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RedirectUri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com.example.app:/oauth2redirect"&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="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AppOAuth2Client&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="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;redirectUri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&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;WebAuthenticator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AuthenticateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;redirectUri&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="n"&gt;CallbackUri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;AuthorizationResult&lt;/span&gt; &lt;span class="n"&gt;result&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;SignInAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the app signs in to its own back end, the back end can complete the sign-in as a web client and hand the app a one-time code. The app exchanges that code for the user's information, and the provider's tokens stay on the server.&lt;/p&gt;




&lt;h2&gt;
  
  
  5️⃣ Design decisions
&lt;/h2&gt;

&lt;p&gt;A few decisions shape how the library behaves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PKCE is on by default, and every provider endpoint must use https.&lt;/li&gt;
&lt;li&gt;Options are checked when the client is created. Invalid options throw immediately, which in ASP.NET Core means at application startup rather than at the first sign-in.&lt;/li&gt;
&lt;li&gt;Expected failures are returned in the result, and only programming errors throw. A failed HTTP request, a state mismatch or an error from the provider ends up in &lt;code&gt;result.Exception&lt;/code&gt;. An unregistered client name is a programming error and throws.&lt;/li&gt;
&lt;li&gt;Users are identified by provider plus user ID, not by email. Emails change, and providers differ in whether they verify them.&lt;/li&gt;
&lt;li&gt;Few dependencies: the net10.0 build of the core package has no package dependencies, the public API has nullable annotations, and the net10.0 build is checked with the trim and AOT analyzers (which confirm it still works when unused code is trimmed at publish and it is compiled ahead of time to native code).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reasoning behind each decision is written up as an ADR (Architecture Decision Record) in the repo's &lt;code&gt;docs/adr/&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  6️⃣ Coming from Bee.OAuth2
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Five packages became three: &lt;code&gt;Bee.OAuth2.WinForms&lt;/code&gt; and &lt;code&gt;Bee.OAuth2.Desktop&lt;/code&gt; are replaced by &lt;code&gt;LoopbackOAuth2Client&lt;/code&gt; in the core package.&lt;/li&gt;
&lt;li&gt;Desktop sign-in now uses the system browser instead of an embedded WebView2 window. Users can see the address bar and keep their existing sessions and password manager, and the app cannot see what they type. Google's policy already rejects embedded browsers, and with WebView2 gone, desktop sign-in is no longer tied to Windows.&lt;/li&gt;
&lt;li&gt;Web sign-in no longer needs a session or the &lt;code&gt;OAUTH2_STATE_KEY&lt;/code&gt; environment variable. Each sign-in has its own cookie, protected by ASP.NET Core Data Protection or &lt;code&gt;MachineKey&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One-line registration in ASP.NET Core: &lt;code&gt;AddOAuth2Client&lt;/code&gt; replaces the &lt;code&gt;OAuth2Manager&lt;/code&gt; singleton you used to assemble by hand.&lt;/li&gt;
&lt;li&gt;Okta and .NET MAUI support are new.&lt;/li&gt;
&lt;li&gt;No more dependency on &lt;code&gt;Bee.Base&lt;/code&gt; or &lt;code&gt;Newtonsoft.Json&lt;/code&gt;. JSON is now parsed with &lt;code&gt;System.Text.Json&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The README has a &lt;a href="https://github.com/polhem-dev/polhem-oauth2#migrating-from-beeoauth2" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; that maps each old type and method to its replacement.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ Wrapping up
&lt;/h2&gt;

&lt;p&gt;Install the core package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Polhem.OAuth2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.com/polhem-dev/polhem-oauth2" rel="noopener noreferrer"&gt;README&lt;/a&gt; covers each kind of application, including how to register the redirect URI with each provider. The repo also includes sample apps: a console app, Windows Forms on .NET and on .NET Framework, ASP.NET Core, and .NET MAUI.&lt;/p&gt;

&lt;p&gt;The library only handles signing in and getting the user's information and tokens. It does not validate the ID token's signature, and it does not manage accounts or permissions after sign-in. If your project is already well served by ASP.NET Core's built-in authentication or an OpenID Connect package, you may not need to switch. It fits best when you have desktop, mobile and web applications at the same time and want to write sign-in the same way in all of them. Feedback, issues and pull requests are welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://datatracker.ietf.org/doc/html/rfc7636" rel="noopener noreferrer"&gt;RFC 7636: Proof Key for Code Exchange&lt;/a&gt;: the PKCE specification&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datatracker.ietf.org/doc/html/rfc8252" rel="noopener noreferrer"&gt;RFC 8252: OAuth 2.0 for Native Apps&lt;/a&gt;: why desktop and mobile apps should use the system browser&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/polhem-oauth2" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/polhem-oauth2&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

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