There’s this frequent notion that you need to use tokens to secure a web api and you can’t use cookies.
That’s not the case. You can do authentication and authorization in a Web Api using cookies the same way you would for a normal web application, and doing so has the added advantage that cookies are easier to setup than for example JWT tokens. There are just a few things you need to be aware.
This blog post is about how you can secure an ASP.NET Core Web Api using cookies (if you are looking for how to secure a Web Api using JWT tokens check out Secure a Web Api in ASP.NET Core and Refresh Tokens in ASP.NET Core Web Api).
Configuration required to make cookies work in a Web Api
If one of the clients of your Web Api is a web application (e.g. an Angular app)
and the Web Api and the Angular application are running in different domains (most common scenario) using cookies will not work without some extra configuration.
This might be the reason why using JWT tokens seems to be what people default to. If you try to setup your authentication the same way you would a traditional web application (e.g. an ASP.NET MVC web app) and then perform AJAX requests for logging in and out you’ll soon discover that they seem to do nothing.
For example, when you try to login to your web api using jQuery:
$.post('https://yourdomain.com/api/account/login', "username=theUsername&password=thePassword")
Your response won’t show any error. If you inspect the response it will even have the Set-Cookie
header but the cookie will seemingly be ignored by the browser.
To add to the confusion you might even have CORS configured correctly for your Web Api and still see this behavior.
Turns out that you have to do some work in the client as well. The next sections describe what you need to do, both in terms of the server configuration and also the client.
You can find an example project here that is nothing more than the ASP.NET default template with authentication set to Individual User accounts stripped out of all the UI and adapted to be consumed as a Web Api. The sample project also contains an Angular application that consumes the Web Api.
Server side configuration
What you need to do server-side is to configure ASP.NET’s cookie authentication middleware and also setup CORS so that your Web Api “declares” it accepts requests from the domain where your client is hosted.
To setup the cookie middleware you have to setup the authentication middleware in your Startup.cs
‘ ConfigurateServices
method:
public void ConfigureServices(IServiceCollection services)
{
//...
services.AddAuthentication(options => {
options.DefaultScheme = "Cookies";
}).AddCookie("Cookies", options => {
options.Cookie.Name = "auth_cookie";
options.Cookie.SameSite = SameSiteMode.None;
options.Events = new CookieAuthenticationEvents
{
OnRedirectToLogin = redirectContext =>
{
redirectContext.HttpContext.Response.StatusCode = 401;
return Task.CompletedTask;
}
};
});
Here I’m naming the cookie authentication scheme as “Cookies” (that’s AddCookie
‘s first parameters). We’ll have to reference this name later when implementing the login endpoint.
I’m also naming the cookie that will be created as auth_cookie
(options.Cookie.Name = "auth_cookie"
). If the consumer of your Web Api is a web client (for example an Angular application) you don’t have to deal with the name of the cookie. However, if you are writing a C# client using HttpClient
you might need to read and store the value of the cookie manually. Having an explicit name is easier to remember than the default name, which is .AspNet.
+ authentication scheme name (in this case that would be .AspNet.Cookies
).
Regarding SameSiteMode
I’m setting it to None
. SameSite is used when setting the Cookie (it controls an attribute with the same name in the Set-Cookie
header). It’s values are Strict
and Lax
. Strict
means that the cookie will only be sent by the browser for requests that originate from the domain of the cookie. With this value the browser won’t even send the cookie if you have a website that has a link to yours.
With Lax
the browser will send the cookie for requests that originate in the cookie’s domain and cross-origin requests that don’t have side effects (i.e. will be sent with a GET but not with a POST). A cross-origin request is a request that is sent from a url different than the destination url (for most browsers even having different ports, e.g.: localhost:8080 to localhost:8081) will make a request be considered cross-origin.
If you set it to SameSiteMode.None
as we did, the samesite
attribute isn’t included. That has the consequence of the browser sending the cookie along for all requests, which is what we want.
As an aside, if you need to debug problems with cookies prefer Firefox’s developer tools to Chrome’s. Chrome will not show you the Set-Cookie
header if it’s not for the domain where the request originated (checked version 67.0.3396.99).
Finally, I’m redefining what happens when the authentication fails. Usually the cookie middleware produces a 302 redirect response to a login page. Since we are building a Web Api we want to send the client a 401 Unauthorized response instead. That’s what the custom OnRedirectToLogin
will do.
When using ASP.NET Core Identity (which is what the demo project uses) this configuration is a little bit different. You won’t have to worry about naming the cookie authentication scheme since ASP.NET Core Identity provides a default value. Also, the redefinition of what happens on the OnRedirectToLogin
is a little bit different (but similar enough that it shouldn’t be a problem to understand after seeing this one).
That’s it for the authentication middleware, but still on the ConfigureServices
method we also need to add CORS. Just add this line:
services.AddCors();
Finally, in Startup.cs
‘ Configure
method add the authentication and CORS middleware to the pipeline (before the MVC pipeline):
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
//...
app.UseCors(policy =>
{
policy.AllowAnyHeader();
policy.AllowAnyMethod();
policy.AllowAnyOrigin();
policy.AllowCredentials();
});
app.UseAuthentication();
//...
app.UseMvc();
Our CORS configuration does not put any restriction on the potential clients of the Web Api. Of particular importance here is the AllowCredentials
option. Without it, the browser will ignore the response to any requests that are sent with Cookies (see the Access-Control-Allow-Credentials section in MDN’s documentation on CORS).
Login and Logout actions
The Login and Logout actions are similar to what you would have for a normal MVC application. The only difference here is that we won’t return any content in the responses, just responses with the appropriate status code.
Here’s an example of how the Login method could look like:
[HttpPost]
public async Task<IActionResult> Login(string username, string password)
{
if (!IsValidUsernameAndPasswod(username, password))
return BadRequest();
var user = GetUserFromUsername(username);
var claimsIdentity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, user.Username),
//...
}, "Cookies");
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
await Request.HttpContext.SignInAsync("Cookies", claimsPrincipal);
return NoContent();
}
Notice that we are referencing the “Cookies” authentication scheme we’ve defined in Startup.cs
.
The Logout method could look like this:
[HttpPost]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync();
return NoContent();
}
In the demo project we rely on ASP.NET Core Identity which provides the UserManager
and SignInManager
classes that provide identical functionality of what is described above.
The Client
When consuming a Web Api that uses cookies using a browser client you need to be aware of some quirks. Namely of the behavior of XMLHttpRequest and the Fetch Api. If your client is not running in a browser (e.g. a C# application), apart from having to know how to save/restore cookies, there are no hurdles.
There are two ways to perform AJAX requests in the browser. Using XMLHttpRequest or using the Fetch Api. Even if you are using some library or framework (e.g. jQuery or Angular) it’s one of these two that is being used.
When you perform a request using any of these options and the response contains a Set-Cookie
header it will be ignored silently. And the documentation on this is not very clear, for example in XMLHttpRequest’s MDN documentation:
XMLHttpRequest.withCredentials
Is a Boolean that indicates whether or not cross-site Access-Control requests should be made using credentials such as cookies or authorization headers.
withCredentials
is the flag you need to set to true so that cookies aren’t ignored when they are set by a response (Set-Cookie
header) and it is also the flag that you need to have so that cookies are sent in requests.
If you dig into the MDN documentation this is described this way:
In addition, this flag is also used to indicate when cookies are to be ignored in the response … XMLHttpRequest from a different domain cannot set cookie values for their own domain unless withCredentials is set to true before making the request…
There you go, that flag serves two different purposes. Reminds me of this tweet:
“So much complexity in software comes from trying to make one thing do two things.” – Ryan Singer
— Programming Wisdom (@CodeWisdom) June 11, 2018
Since it’s most likely you will not be making requests with XMLHttpRequest
manually I’ll abstain from including an example for it here and include instead one for jQuery, another for Angular and another with the Fetch Api.
JQuery
For jQuery you can perform a request with withCredentials
set to true this way:
$.ajax({
url: 'http://yourdomain.com/api/account/login?username=theUsername&password=thePassword',
method: 'POST',
xhrFields: {
withCredentials: true
}
});
Every request needs to have the withCredentials
flag.
Doing this with with $.ajax
can get tedious fast. Thankfully you can just use $.ajaxSetup
and set it there:
$.ajaxSetup({xhrFields: {withCredentials: true}});
Now every subsequent request you perform with jQuery ($.get, $.post, etc) will be done with the withCredentials
flag set to true.
Angular
With Angular you can specify options on each call using HttpClient
from @angular/common/http
, for example:
this.httpClient.post<any>(`http://yourdomain.com/api/account/login?username=theUsername&password=thePassword`, {}, {
withCredentials: true
}).subscribe(....
Or, more conveniently, you can create an HttpInterceptor that will add that option to every request for you:
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpEvent, HttpRequest, HttpHandler } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
@Injectable()
export class AddWithCredentialsInterceptorService implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(req.clone({
withCredentials: true
}));
}
}
Fetch Api
If you are using the more modern Fetch API you need to add the property credentials
with value include
with every request that might result in a response that creates cookies or requests for which cookie are to be sent with.
Here’s an example of a post request:
fetch('http://yourdomain.com/api/account/login?username=theUsername&password=thePassword', {
method: 'POST',
credentials: 'include'
}).then...
.Net Client
To create a .Net Client you’d use HttpClient.
HttpClient will take care of storing the cookie when a response sends it and it will send it for your when you perform a request. You just need to keep the HttpClient instance after you log in, which is the recommended way of doing it anyway.
Here’s how you could login and then perform an authenticated request:
var client = new HttpClient();
var loginResponse = await client.PostAsync("http://yourdomain.com/api/account/login?username=theUsername&password=thePassword", null);
if (!loginResponse.IsSuccessStatusCode){
//handle unsuccessful login
}
var response = await client.GetAsync("http://yourdomain.com/api/anEndpointThatRequiresASignedInUser/");
One thing you might want to do is to save the authentication cookie and restore it later.
Imagine a scenario where your user closes your application and you want to support the user being able to return later an not having to log in.
It is possible to do this with HttpClient
, but you need to initialize it a little differently:
CookieContainer cookieContainer = new CookieContainer();
HttpClientHandler handler = new HttpClientHandler
{
CookieContainer = cookieContainer
};
handler.CookieContainer = cookieContainer;
var client = new HttpClient(handler);
var loginResponse = await client.PostAsync("http://yourdomain.com/api/account/login?username=theUsername&password=thePassword", null);
if (!loginResponse.IsSuccessStatusCode){
//handle unsuccessful login
}
var authCookie = cookieContainer.GetCookies(new Uri("http://yourdomain.com")).Cast<Cookie>().Single(cookie => cookie.Name == "auth_cookie");
//Save authCookie.ToString() somewhere
//authCookie.ToString() -> auth_cookie=CfDJ8J0_eoL4pK5Hq8bJZ8e1XIXFsDk7xDzvER3g70....
To restore a cookie after creating the CookieContainer
you can call the SetCookies
method on it:
cookieContainer.SetCookies(new Uri("http://yourdomain.com"), "auth_cookie=CfDJ8J0_eoL4pK5Hq8bJZ8e1XIXFsDk7xDzvER3g70...");
Conclusion
Event though this is a long post, setting up cookies in you Web Api is not that hard. You just need to keep a few things in mind.
Namely, you need to make sure that your cookie is not being generated with a samesite
attribute. To do this you should check the Set-Header
header that comes in the login response.
Do this using Firefox instead of Chrome’s developer tools. Chrome (at least the version I’m running 67.0.3396.99) does not display the Set-Cookie
header if it’s for a different domain than the one from where the request was performed.
The next thing you have to make sure is that you’ve configured CORS correctly. Of particular importance is making sure you have AllowsCredentials
in your CORS policy.
Finally, if you are running a web client, make sure you have the withCredentials
flag set to true on every request or credentials: 'include'
for the Fetch Api.