<?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: Remigiusz Zalewski</title>
    <description>The latest articles on DEV Community by Remigiusz Zalewski (@remigiuszzalewski).</description>
    <link>https://dev.to/remigiuszzalewski</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%2F4122305%2Fa1ef1bb1-982f-4603-b600-0c4a894fc367.jpg</url>
      <title>DEV Community: Remigiusz Zalewski</title>
      <link>https://dev.to/remigiuszzalewski</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/remigiuszzalewski"/>
    <language>en</language>
    <item>
      <title>Api Key Authentication in ASP .NET Core</title>
      <dc:creator>Remigiusz Zalewski</dc:creator>
      <pubDate>Sat, 12 Sep 2026 16:08:34 +0000</pubDate>
      <link>https://dev.to/remigiuszzalewski/api-key-authentication-in-asp-net-core-2gng</link>
      <guid>https://dev.to/remigiuszzalewski/api-key-authentication-in-asp-net-core-2gng</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Not every API needs users, tokens, and a login flow. An internal service that another service calls, a webhook receiver, a partner integration - these often just need "prove you are an allowed caller", and an API key in a header does that with almost no ceremony. The client sends a secret string, the server checks it, done.&lt;/p&gt;

&lt;p&gt;This video implements API key authentication in ASP.NET Core on .NET 9 three different ways, because where you put the check matters: middleware for the whole app, an MVC authorization filter for controllers, and an endpoint filter for minimal API routes. All three read the header name and expected key from configuration through the Options pattern.&lt;/p&gt;

&lt;p&gt;🎬 Watch the full video here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://youtu.be/RPd1md_9oRM" rel="noopener noreferrer"&gt;https://youtu.be/RPd1md_9oRM&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the key and header name live
&lt;/h2&gt;

&lt;p&gt;Both values come from &lt;code&gt;appsettings.json&lt;/code&gt;, bound to a typed options class rather than read as loose strings:&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;ApiKeyOptions&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;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;AuthenticationApiKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Authentication:ApiKey"&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;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;HeaderName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&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="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&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;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&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;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="n"&gt;Configure&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApiKeyOptions&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;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ApiKeyOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationApiKey&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every enforcement approach below injects &lt;code&gt;IOptions&amp;lt;ApiKeyOptions&amp;gt;&lt;/code&gt;. In production the actual key value belongs in a secret store (user secrets locally, Key Vault or environment variables in the cloud), never committed in &lt;code&gt;appsettings.json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 1: middleware
&lt;/h2&gt;

&lt;p&gt;Middleware runs for every request in the pipeline. It reads the configured header, compares it to the configured key, and short-circuits with &lt;code&gt;401&lt;/code&gt; if it does not match:&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;if&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;Request&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="n"&gt;_options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HeaderName&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;extractedApiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;extractedApiKey&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;Key&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;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status401Unauthorized&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;context&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;WriteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Api Key is invalid"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use middleware when the &lt;strong&gt;entire API&lt;/strong&gt; is key-protected and there are no public endpoints. It is the bluntest instrument - there is no per-route opt-out without adding path checks inside it, which gets ugly fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 2: MVC authorization filter
&lt;/h2&gt;

&lt;p&gt;For controllers, an &lt;code&gt;IAuthorizationFilter&lt;/code&gt; runs as part of MVC's filter pipeline and can be applied exactly where you want 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiKeyAuthFilter&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IAuthorizationFilter&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;void&lt;/span&gt; &lt;span class="nf"&gt;OnAuthorization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AuthorizationFilterContext&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* header missing or wrong */&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;Result&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;UnauthorizedObjectResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Api Key is invalid"&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;Because it has a constructor dependency (&lt;code&gt;IOptions&amp;lt;ApiKeyOptions&amp;gt;&lt;/code&gt;), register it in DI and apply it with &lt;code&gt;[ServiceFilter(typeof(ApiKeyAuthFilter))]&lt;/code&gt; on a controller or action. Now &lt;code&gt;[AllowAnonymous]&lt;/code&gt;-style selectivity is trivial: put the attribute on the controllers that need it and leave the rest open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 3: minimal API endpoint filter
&lt;/h2&gt;

&lt;p&gt;Minimal APIs have their own filter abstraction, &lt;code&gt;IEndpointFilter&lt;/code&gt;, added per route or per route group:&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;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;"/weatherforecastminimal"&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="cm"&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;AddEndpointFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApiKeyEndpointFilter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter's &lt;code&gt;InvokeAsync&lt;/code&gt; checks the header and either writes a &lt;code&gt;401&lt;/code&gt; or calls &lt;code&gt;next(context)&lt;/code&gt; to continue. Apply it to a &lt;code&gt;MapGroup&lt;/code&gt; and every route in that group is protected in one line. This is the idiomatic choice for a minimal API codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real gotcha in the demo
&lt;/h2&gt;

&lt;p&gt;The endpoint filter in the video checks the key, writes the &lt;code&gt;401&lt;/code&gt; body on failure, but then still calls &lt;code&gt;await next(context)&lt;/code&gt;. To actually short-circuit, it needs to &lt;code&gt;return&lt;/code&gt; immediately after writing the unauthorized response instead of falling through to the next delegate. It is a one-word fix and a good reminder that with filters, &lt;em&gt;not&lt;/em&gt; calling &lt;code&gt;next&lt;/code&gt; is how you stop the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constant-time comparison
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;extractedApiKey != _options.Key&lt;/code&gt; is a normal string comparison, which can leak timing information to an attacker probing the key byte by byte. For a real deployment, compare with a fixed-time method (&lt;code&gt;CryptographicOperations.FixedTimeEquals&lt;/code&gt; over the UTF-8 bytes) so every comparison takes the same time regardless of how many characters match.&lt;/p&gt;

&lt;h2&gt;
  
  
  When API keys are the wrong tool
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You need to know &lt;em&gt;which user&lt;/em&gt; is acting, not just which application. Use real user auth.&lt;/li&gt;
&lt;li&gt;Keys are going to end up in browser code. A key in client-side JavaScript is public - anyone can read it.&lt;/li&gt;
&lt;li&gt;You need granular, revocable, per-scope permissions. That is OAuth's job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;API keys shine for trusted server-to-server calls where the client can keep a secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Committing the key.&lt;/strong&gt; Treat it like a password: secret store, not source control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No revocation story.&lt;/strong&gt; A single hardcoded key means a leak forces a redeploy. Real systems store hashed keys in a database so individual keys can be rotated and revoked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting to short-circuit.&lt;/strong&gt; Writing a &lt;code&gt;401&lt;/code&gt; body but still invoking the rest of the pipeline runs the endpoint anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain &lt;code&gt;==&lt;/code&gt; comparison.&lt;/strong&gt; Use a fixed-time comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixing approaches.&lt;/strong&gt; Pick one enforcement point for a given surface; stacking middleware and a filter doing the same check is just confusing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;API key auth is a good fit for server-to-server APIs where OAuth is overkill and you only need to identify the calling application.&lt;/li&gt;
&lt;li&gt;Bind the header name and key to a typed &lt;code&gt;ApiKeyOptions&lt;/code&gt; via the Options pattern; keep the real value in a secret store.&lt;/li&gt;
&lt;li&gt;Middleware protects the whole app; an MVC &lt;code&gt;IAuthorizationFilter&lt;/code&gt; (via &lt;code&gt;[ServiceFilter]&lt;/code&gt;) protects chosen controllers; an &lt;code&gt;IEndpointFilter&lt;/code&gt; protects chosen minimal API routes or groups.&lt;/li&gt;
&lt;li&gt;To short-circuit a filter, write the response and &lt;code&gt;return&lt;/code&gt; - do not call &lt;code&gt;next&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Compare keys in constant time and design for rotation and revocation from the start.&lt;/li&gt;
&lt;li&gt;Never ship an API key in client-side code.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get the Full Source Code
&lt;/h2&gt;

&lt;p&gt;The complete runnable solution - all three enforcement approaches, the options binding, and both controller and minimal API endpoints to test against - is available to Patreon supporters. If you want to try each approach with a real header instead of rebuilding it from the walkthrough above, you can find it on &lt;a href="https://www.patreon.com/remigiuszzalewski" rel="noopener noreferrer"&gt;Patreon&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://remigiuszzalewski.com/blog/api-key-authentication-aspnet-core" rel="noopener noreferrer"&gt;remigiuszzalewski.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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