I'm implementing for first time a login Auth with HTTpOnly Cookie. In my case, the cookie it's created when user calls login method in a Python service with fastapi and uvicorn.
I've read the MDN documentation to implement the expires property and so, the browser delete this cookie when the time expires.
I've implemented the Cookie in Python with http.cookies and Morsel to apply the HttpOnly property like this:
from http import cookies
from fastapi import FastAPI, Response, Cookie, Request
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
mytoken = 'blablabla'
def getUtcDate():
sessionDate = datetime.now()
sessionDate += timedelta(minutes=2)
return sessionDate.strftime('%a, %d %b %Y %H:%M:%S GMT')
@app.get('cookietest')
def getCookie(response: Response):
cookie = cookies.Morsel()
cookie['httponly'] = True
cookie['version'] = '1.0.0'
cookie['domain'] = '127.0.0.1'
cookie['path'] = '/'
cookie['expires'] = getUtcDate()
cookie['max-age'] = 120
cookie['samesite'] = 'Strict'
cookie.set("jwt", "jwt", mytoken)
response.headers.append("Set-Cookie", cookie.output())
return {'status':'ok'}
Doing this, the Cookies looks correctly in the browser when I call the 'cookietest' endpoint, the evidence:
As you can see in the picture, the cookie has an expiration datetime in Expires / Max-Age:"Wed, 12 Oct 2022 11:24:58 GMT", 2 minutes after logging in (if the user logging at 14:05:00, the cookies expires at 14:07:00)
My problem is that any browser doesn't delete the cookie when the expire time has been exceeded, so this it's confusing me. If I let several minutes pass and then make a request to another endpoint (like http://127.0.0.1:8000/info), the cookie still exists in http headers.
What is the problem? What i'm doing wrong? I'm reading a lot of documentation about cookie storing and expiration and I can't see anything about this issue.
Many thanks Regards
As Chris says, using the set_cookie method from FastApi the problem was solved.
I still wonder why the MSD documentation indicates that the date format must be a specific one which does not cause the browser to delete the Cookie, but indicating the time in seconds works correctly.
@app.get("/cookietest")
async def cookietest(response: Response):
response.set_cookie(
key='jwt',
value=getToken(),
max_age=120,
expires=120,
path='/',
secure=False,
httponly=True,
samesite="strict",
domain='127.0.0.1'
)
return {"Result": "Ok"}
When using the expires
flag, the date must be exactly in the format you are currently using, as well as in the GMT
(Greenwich Mean Time) timezone. The reason that your cookie does not get expired 2 minutes after it has been created is because you are using datetime.now(), which returns the current local date and time.
Thus, for instance, if your current local timezone is GMT+2
and time is 20:30:00
(hence, GMT
time is 18:30:00
), creating a cookie that expires at 20:32:00 GMT
will actually tell the web browser to delete this cookie in 2 hours and 2 minutes (from the time it has been created). If you look at the cookie's Expires / Max-Age
column in your web browser's DevTools (e.g., on Chrome, go to Network
tab in the DevTools, click on the request's name and then on the Cookies
tab), you will notice a Z
at the end of the datetime, which means UTC
(Coordinated Universal Time)—that is, an offset from UTC of zero hours-minutes-seconds. You can check the response headers as well, where you can see the cookie's expires
flag set to 20:32:00 GMT
. There is no noticable time difference between UTC
and GMT
(if you would like to learn more about their differences, have a look at this post).
Hence, you could either replace .now()
with .utcnow()
in your code:
from datetime import timedelta, datetime
def get_expiry():
expiry = datetime.utcnow()
expiry += timedelta(seconds=120)
return expiry.strftime('%a, %d-%b-%Y %T GMT')
or use time.gmtime(), passing as secs
argument the time.time() (which returns the time in seconds) plus the desired lease time (in seconds):
import time
def get_expiry():
lease = 120 # seconds
end = time.gmtime(time.time() + lease)
return time.strftime('%a, %d-%b-%Y %T GMT', end)
You could use either of the two methods above in your example, as follows:
cookie['expires'] = get_expiry()
You could also use the undocumented way of passing the expiry time directly in seconds instead. For instance:
cookie['expires'] = 120
An alternative to expires
is the max-age
flag , which specifies the cookie's expiration in seconds from the current moment (similar to the above way). If set to zero or a negative value, the cookie is deleted immediately. Example:
cookie['max-age'] = 120
If both expires
and max-age
are set, max-age
has precedence (see relevant documentation on MDN).
Also, as per RFC 6265:
4.1.2.1. The
Expires
AttributeThe
Expires
attribute indicates the maximum lifetime of the cookie, represented as the date and time at which the cookie expires. The user agent is not required to retain the cookie until the specified date has passed. In fact, user agents often evict cookies due to memory pressure or privacy concerns.4.1.2.2. The
Max-Age
AttributeThe
Max-Age
attribute indicates the maximum lifetime of the cookie, represented as the number of seconds until the cookie expires. The user agent is not required to retain the cookie for the specified duration. In fact, user agents often evict cookies due to memory pressure or privacy concerns.NOTE: Some existing user agents do not support the Max-Age attribute. User agents that do not support the Max-Age attribute ignore the attribute.
If a cookie has both the
Max-Age
and theExpires
attribute, theMax-Age
attribute has precedence and controls the expiration date of the cookie. If a cookie has neither theMax-Age
nor theExpires
attribute, the user agent will retain the cookie until "the current session is over" (as defined by the user agent).
Also note, as mentioned in MDN documentation regarding the expires
flag:
Warning: Many web browsers have a session restore feature that will save all tabs and restore them the next time the browser is used. Session cookies will also be restored, as if the browser was never closed.
Another thing to note is that, since Sep 2022, Chrome limits the cookie's max-age
to 400 days:
When cookies are set with an explicit
Expires/Max-Age
attribute the value will now be capped to no more than 400 days in the future. Previously, there was no limit and cookies could expire as much as multiple millennia in the future.
set_cookie
methodIt should also be noted that FastAPI/Starlette provides an easier way to set cookies on the Response
object, using the set_cookie
method, as described in this answer. As per Starlette documentation:
key
- A string that will be the cookie's keyvalue
- A string that will be the cookie's valuemax_age
- An integer that defines the lifetime of the cookie in seconds. A negative integer or a value of0
will discard the cookie immediately.Optional
expires
- An integer that defines the number of seconds until the cookie expires.Optional
- ...
Example (see FastAPI's documentation):
from fastapi import FastAPI, Response
app = FastAPI()
@app.post('/')
def create_cookie(response: Response):
response.set_cookie(key='token', value='token-value', max_age=120, expires=120, httponly=True)
return {'message': 'success'}