Description
First of all, I want to thank you for FastAPI - It's has been a while since I have been this excited about programming for the web. FastAPI is, so far, a really interesting project.
Looking through the documentation, I can see a very clear and concise practical guide to implement JWT tokens. I can see that the access token is returned as part of the response body:
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
user_dict = fake_users_db.get(form_data.username)
if not user_dict:
raise HTTPException(status_code=400, detail="Incorrect username or password")
user = UserInDB(**user_dict)
hashed_password = fake_hash_password(form_data.password)
if not hashed_password == user.hashed_password:
raise HTTPException(status_code=400, detail="Incorrect username or password")
return {"access_token": user.username, "token_type": "bearer"}
This appears to be a requirement for the /docs to work as expected (where one can login and execute calls on the fly), this is really cool functionality, but it seems to be tied down to the response body.
I would like to be able to set a secure and httpOnly cookie to hold the access token, as I feel that exposing the access token as part of the response body is detrimental to the security of my application. At the same time, I would like the /docs to remain functional with a cookie based approach.
Would this be straightforward to accomplish? Is this at all supported out of the box by FastAPI?
So I actually got this to work with some simple changes, here is a small writeup for anyone that might be interested:
First thing is to update the /token route:
@router.post("/token", tags=["auth"])
async def auth_token(response: Response, form_data: OAuth2PasswordRequestForm = Depends()):
# auth logic here...
access_token = create_access_token(
data={"sub": username}, expires_delta=access_token_expires
).decode("utf-8")
response.set_cookie(key="access_token",value=f"Bearer {access_token}", httponly=True)
return
Notice that I need to .decode("utf-8")
the access token (else you run into Invalid header padding
errors when decoding the jwt). I also set my httponly cookie and return an empty response.
Now we need to create a similar class to OAuth2PasswordBearer
, this is the class FastAPI needs to find the token url:
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
So this is the new class we need to replace it with:
class OAuth2PasswordBearerWithCookie(OAuth2):
def __init__(
self,
tokenUrl: str,
scheme_name: str = None,
scopes: dict = None,
auto_error: bool = True,
):
if not scopes:
scopes = {}
flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes})
super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)
async def __call__(self, request: Request) -> Optional[str]:
authorization: str = request.cookies.get("access_token")
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
if self.auto_error:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
else:
return None
return param
This is the same exact code for OAuth2PasswordBearer
with a small modification: authorization: str = request.cookies.get("access_token")
- Instead of grabbing the token from the Authentication header, we get it from the access_token
cookie.
Your code now becomes:
oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="/auth/token")
This works with /docs as well since the browser always sends back httonly cookies!
FYI - Blog series using this approach with FastAPI by Nils de Bruin - https://medium.com/@nils_29588
In the future will this method be included in the docs? I think a httponly cookie is the best way to store the jwt token, and not sending in the body with json. Also thanks @joaodlf i will try your solution in my project in the next couple of days.
Edit: Tried it today and it works nicely.
Isn't this approach still vulnerable to CSRF attacks?
Adding the SameSite=Lax
flag to the cookie could mitigate most of the vulnerabilities that come with cookies (and works in most modern browsers). Otherwise you would have to add some kind of verification cookie to your frontend and implement additional logic to handle this (similar to Django csrf_token).
@cbows yes it is still vulnerable to CSRF. But it's XSS safe (with httponly=true), which sending in the body (and storing on the client) isn't. SameSite is not fully supported in all browsers https://caniuse.com/#search=samesite yet. But i do think as well, that it does minimize the vulnerability a lot, if you don't have some cross-origin scenarios.
Otherwise yes you have to use a token. in fastapi you could maybe implement it in your jwt claim and store it on the client. and with every request you send it in the header and compare it with the claim
I think stuff like this would be awesome to include in the docs. fastapi and also the docs are really awesome. Thats why i think to have the best possible security in the docs is really nice and a big selling point :-)
I totally agree with you. This definitely should go in the docs. With SameSite=Lax it might even be superior to the current approach in terms of default security.
According to starlette docs, samesite='lax'
is the default when setting cookies.
I have to look if it's beeing set in my cookie. But that would make it even more relevant to include this in the docs or even implement a class in fastapi
@cbows I just checked my cookie and it does not set samesite. current version of fastapi (0.54.1) uses starlette (0.13.2). If you check starlette the current version is 0.13.4 and there it is set in the code. so fastapi first needs to update to 0.13.4 as a dependency. right now we have to set it manually
I agree the docs should be updated to an example with HttpOnly cookies since I think that's best practice to protect session tokens from XSS
Just as a heads up, you could follow the way that flask_jwt_extended does things where the function that sets the tokens as cookies also creates csrf tokens:
https://flask-jwt-extended.readthedocs.io/en/stable/tokens_in_cookies/
FYI, @wshayes broken link (I think) should lead to this: https://archive.is/UsaXo
I agree the docs should be updated to an example with HttpOnly cookies since I think that's best practice to protect session tokens from XSS
Perhaps @SelfhostedPro's suggestion which shows:
NOTE: This is just a basic example of how to enable cookies. This is
vulnerable to CSRF attacks, and should not be used as is. See
csrf_protection_with_cookies.py for a more complete example!
And leads to: https://archive.is/Bv1ub
Could be incorporated for a fully secure solution whilst maintaining old browser support
Most helpful comment
So I actually got this to work with some simple changes, here is a small writeup for anyone that might be interested:
First thing is to update the /token route:
Notice that I need to
.decode("utf-8")
the access token (else you run intoInvalid header padding
errors when decoding the jwt). I also set my httponly cookie and return an empty response.Now we need to create a similar class to
OAuth2PasswordBearer
, this is the class FastAPI needs to find the token url:So this is the new class we need to replace it with:
This is the same exact code for
OAuth2PasswordBearer
with a small modification:authorization: str = request.cookies.get("access_token")
- Instead of grabbing the token from the Authentication header, we get it from theaccess_token
cookie.Your code now becomes:
This works with /docs as well since the browser always sends back httonly cookies!