When you create an HTTP API, one of the most important consideration is: how will we accept access token from client? 2 of the most popular options are utilizing Bearer Token Header, and using HttpOnly Cookie. Both have pros & cons, however I won't discuss it at length here (as its been discussed and debated numerous of times before). I'll just say that in one of my recent work, me and my colleagues has decided that cookies is the most suitable for our needs and agreed to use it for our API.
However after struggling to implement an authentication flow based on it, I've found that implementing a proper cookie-based authentication scheme could be harder than it look. I've also found that the documentation about this is somewhat scattered and partial. Because of it I tried to gather and outline various knowledge I've encountered, and hope that this article could help other unfortunate souls so they don't have to go through the same suffering as I did.
Here I mainly use NestJs as example, but the knowledge should be applicable for other languages or frameworks.
Level 0: cookies with regular settings
Changeable by JavaScript in browsers. May be useful to send persistent (user changeable) data to backend. just make sure to validate the input just like a regular user input. example use case is for saving language setting for website. user could set cookies via javascript, which would be sent for every later requests to the server. Example in NestJs:
// at controller
@Get('/get_cookies')
getGetCookies(@Req() req: Request): string {
return req.cookies // get all cookies sent by client/browser
}
@Get('/set_cookies')
getSetCookies(@Res({ passthrough: true }) res: Response): string {
res.cookie('cookie_name', 'cookie_value')
return { status: 'ok'}
}
Level 1: httpOnly cookies, for usage by the same subdomain (usually fullstack app)
"httpOnly" flag allows cookies which is not accessible by browser's javascript, good for storing sensitive info like access token. by default it would be sent at each subsequent non-ajax request, including normal link clicks & form submissions. optionally add "secure" flag. Example in NestJs:
// at controller
@Get('/set_cookies')
getSetCookies(@Res({ passthrough: true }) res: Response): string {
res.cookie('cookie_name', 'cookie_value', {
httpOnly: true,
})
return { status: 'ok'}
}
Level 2: Level 1, but called from frontend
In the past the term for this type of request is "Ajax", nowadays people usually using fetch API or custom library like Axios. To send cookies we need to setup frontend with "credentials: include". More information about this 'credentials' setting in MDN. Example using Fetch API:
fetch('https://example.com/endpoint', {
credentials : 'include',
})
.then(r => {
if (r.ok) console('success');
})
Level 3: Level 2, but Frontend & Backend at different subdomains (but still under the same domain)
By default browsers would block these kind of requests because of a security mechanism called CORS (Cross Origin Resource Sharing). This mechanism designed to protect browser users from many kind of attack involving browser requests, including CSRF (Cross-Site Request Forgery), Cookie Hijacking, Session Hijacking, etc. To enable cross-subdomain communication between frontend & backend, and also to protect us from mentioned attacks, we need to set CORS Header settings from backend side of things. In short, we need to configure Cross-Origins-Allow-Credentials
and Cross-Origin-Allow-Origin
Headers. Again example in NestJs:
// main.ts
const app = await NestFactory.create(AppModule);
app.enableCors({
// read more options here: https://github.com/expressjs/cors#configuration-options
origins: [
'https://frontend.example.com',
],
credentials: true,
});
await app.listen(3000);
FYI, we could also set origins to wildcard value (*
) but this is discouraged for most kind of public access. This value is also blocked for more advanced usages, for example the SameSite:None
cookie flag which we will discuss later. Instead, in some web frameworks we could configure some kind of pattern matching to allow more flexible URL. Of course using 'catch-all' patterns would defeat the purpose, so try to limit our pattern's scope as narrow as possible. Again example in NestJs:
// main.ts
const app = await NestFactory.create(AppModule);
app.enableCors({
// read more options here: https://github.com/expressjs/cors#configuration-options
origins: [
// using regex, enable all subdomains of example.com
/https:\/\/[a-z0-9\.]*example\.com/,
],
credentials: true,
});
await app.listen(3000);
Level 4: Level 3, but BE & FE at different domains
At production environment, this setup is actually discouraged as it opens our website to more attacks (although CORS setting somewhat helps reduce the risk). However this setup is common for development as it allows frontend developers to test remote API from local environment. For production usage, try stick to using the same root domain.
To enable this scenario, we need to add samesite: none
& secure: true
cookie flag when backend is storing cookies. Example in NestJs:
// at controller
@Get('/set_cookies')
getSetCookies(@Res({ passthrough: true }) res: Response): string {
res.cookie('cookie_name', 'cookie_value', {
httpOnly: true,
sameSite: "none",
secure: true,
})
return { status: 'ok'}
}
Level 5: Level 4, but mixed between Ajax requests & regular requests
I'm struggling with this. Example use case is passing access token data between BE & FE. Access token is set when user visit BE endpoint, then BE redirect to FE page. Common for some SSO flows, like Laravel Socialite's SSO Flow which utilized browser's redirect. Chrome still support this flow, but currently Firefox doesn't. On Firefox the cookies won't be send to backend from the frontend after redirect.
Fortunately from what I found, this problem is mainly when we use cross-domain request. For cross-subdomain (under the same domain) requests, it seems that there are no problems.
Gotchas
Cookies will only be sent to the same the host with the one set it
even "Domain" cookie flag is considered invalid if its different from requesting host. I've stumbled into a problem about this when I develop local API and publish it with public url using Localhost.run. So when the API set a cookie in browser, I didn't realize that the URL I visit is actually the local one instead of the public one (although its actually the same API). So of course the the cookies couldn't be read because its set & read by different domains/hosts.
Cookies are not 100% temper proof, even on httpOnly & secure settings
As in everything in technology, no matter how advanced the security measures are, in the end its still made by humans with all their flaws. Wikipedia page for HTTP Cookie lists a couple of said vulnerabilities.
References
- StackOverflow answer which outline the requirement for cross-domain Cookie storage
- https://github.com/benawad/how-to-debug-cookies/blob/master/README.md
- How to debug cookies when they dont work (youtube)
- Mozilla Developer Docs on Cookies
- My proof of concept repo for cross-domain cookie setup using NestJs and Laravel
Top comments (1)
Thanks it helps