Skip to content

Commit 007ae67

Browse files
author
IndominusByte
committed
add docs about jwt in cookies
1 parent 035394d commit 007ae67

File tree

4 files changed

+203
-0
lines changed

4 files changed

+203
-0
lines changed

docs/usage/jwt_in_cookies.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
Highly recommended using JWT in cookies, if your frontend interaction with the backend, your frontend may be storing JWT in the browser localStorage or sessionStorage. There is nothing wrong with this, but if you have any sort of XSS vulnerability on your site, an attacker will be able to trivially steal your tokens. If you want some additional security on your site, you can save your JWT in an httponly cookies. Which keeps javascript cannot be able to access the cookies.
2+
3+
Here is a basic example of how to store JWT in cookies:
4+
5+
```python
6+
{!../examples/jwt_in_cookies.py!}
7+
```
8+
9+
This isn't the full story. However now we can keep our cookies from being stolen via XSS attacks, but session cookies vulnerable to CSRF attacks. To combat CSRF attacks we are going to use a technique called double submit cookie pattern. Double submitting cookies is defined as sending a random value in both a cookie and as a request parameter, with the server verifying if the cookie value and request value are equal.
10+
11+
<figure>
12+
<img src="https://miro.medium.com/max/648/1*WP_VXYjJxUyqfrul8K-4uw.png"/>
13+
<figcaption>
14+
<a href="https://medium.com/@kaviru.mihisara/double-submit-cookie-pattern-820fc97e51f2">
15+
Double Submit Cookie Pattern
16+
</a>
17+
</figcaption>
18+
</figure>
19+
20+
This tokens is saved in a cookie with httponly set to True, so it cannot be accessed via javascript. We will then create a secondary cookie that contains an only random string, but has httponly set to False so that it can be accessed via javascript running on your website.
21+
22+
Now in order to access a protected endpoint, you will need to add a custom header that contains the random string in it, and if that header doesn’t exist or it doesn’t match the string that is stored in the JWT, the requester will be kicked out as unauthorized.
23+
24+
To break this down, if an attacker attempts to perform a CSRF attack they will send the JWT *(via cookie)* to protected endpoint, but without the random string in the request headers, they won't be able to access the endpoint. They cannot access the random string unless they can run javascript on your website *likely via an XSS attack*, and if they are able to perform an XSS attack, they will not be able to steal the actual access and refresh JWT, as javascript is still not able to access those httponly cookies.
25+
26+
No system is secure. If an attacker can perform an XSS attack they can still access protected endpoints from people who visit your site. However, it is better than if they were able to steal the access and refresh tokens from local/session storage, and use them whenever they wanted.
27+
28+
Here is an example of using cookies with CSRF protection:
29+
30+
```python
31+
{!../examples/csrf_protection_cookies.py!}
32+
```

examples/csrf_protection_cookies.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from fastapi import FastAPI, HTTPException, Depends, Request
2+
from fastapi.responses import JSONResponse
3+
from fastapi_jwt_auth import AuthJWT
4+
from fastapi_jwt_auth.exceptions import AuthJWTException
5+
from pydantic import BaseModel
6+
7+
"""
8+
By default, the CRSF cookies will be called csrf_access_token and
9+
csrf_refresh_token, and in protected endpoints we will look
10+
for the CSRF token in the 'X-CSRF-Token' headers. only certain
11+
methods should define CSRF token in headers default is ('POST','PUT','PATCH','DELETE')
12+
"""
13+
14+
app = FastAPI()
15+
16+
class User(BaseModel):
17+
username: str
18+
password: str
19+
20+
class Settings(BaseModel):
21+
authjwt_secret_key: str = "secret"
22+
# Configure application to store and get JWT from cookies
23+
authjwt_token_location: set = {"cookies"}
24+
# Only allow JWT cookies to be sent over https
25+
authjwt_cookie_secure: bool = False
26+
# Enable csrf double submit protection. default is True
27+
authjwt_cookie_csrf_protect: bool = True
28+
# Change to 'lax' in production to make your website more secure from CSRF Attacks, default is None
29+
# authjwt_cookie_samesite: str = 'lax'
30+
31+
@AuthJWT.load_config
32+
def get_config():
33+
return Settings()
34+
35+
@app.exception_handler(AuthJWTException)
36+
def authjwt_exception_handler(request: Request, exc: AuthJWTException):
37+
return JSONResponse(
38+
status_code=exc.status_code,
39+
content={"detail": exc.message}
40+
)
41+
42+
@app.post('/login')
43+
def login(user: User, Authorize: AuthJWT = Depends()):
44+
"""
45+
With authjwt_cookie_csrf_protect set to True, set_access_cookies() and
46+
set_refresh_cookies() will now also set the non-httponly CSRF cookies
47+
"""
48+
if user.username != "test" and user.password != "test":
49+
raise HTTPException(status_code=401,detail="Bad username or password")
50+
51+
# Create the tokens and passing to set_access_cookies or set_refresh_cookies
52+
access_token = Authorize.create_access_token(subject=user.username)
53+
refresh_token = Authorize.create_refresh_token(subject=user.username)
54+
55+
# Set the JWT and CSRF double submit cookies in the response
56+
Authorize.set_access_cookies(access_token)
57+
Authorize.set_refresh_cookies(refresh_token)
58+
return {"msg":"Successfully login"}
59+
60+
@app.post('/refresh')
61+
def refresh(Authorize: AuthJWT = Depends()):
62+
Authorize.jwt_refresh_token_required()
63+
64+
current_user = Authorize.get_jwt_subject()
65+
new_access_token = Authorize.create_access_token(subject=current_user)
66+
# Set the JWT and CSRF double submit cookies in the response
67+
Authorize.set_access_cookies(new_access_token)
68+
return {"msg":"The token has been refresh"}
69+
70+
@app.delete('/logout')
71+
def logout(Authorize: AuthJWT = Depends()):
72+
"""
73+
Because the JWT are stored in an httponly cookie now, we cannot
74+
log the user out by simply deleting the cookie in the frontend.
75+
We need the backend to send us a response to delete the cookies.
76+
"""
77+
Authorize.jwt_required()
78+
79+
Authorize.unset_jwt_cookies()
80+
return {"msg":"Successfully logout"}
81+
82+
@app.get('/protected')
83+
def protected(Authorize: AuthJWT = Depends()):
84+
Authorize.jwt_required()
85+
86+
current_user = Authorize.get_jwt_subject()
87+
return {"user": current_user}

examples/jwt_in_cookies.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from fastapi import FastAPI, HTTPException, Depends, Request
2+
from fastapi.responses import JSONResponse
3+
from fastapi_jwt_auth import AuthJWT
4+
from fastapi_jwt_auth.exceptions import AuthJWTException
5+
from pydantic import BaseModel
6+
7+
"""
8+
Note: This is just a basic example how to enable cookies.
9+
This is vulnerable to CSRF attacks, and should not be used this example.
10+
"""
11+
12+
app = FastAPI()
13+
14+
class User(BaseModel):
15+
username: str
16+
password: str
17+
18+
class Settings(BaseModel):
19+
authjwt_secret_key: str = "secret"
20+
# Configure application to store and get JWT from cookies
21+
authjwt_token_location: set = {"cookies"}
22+
# Disable CSRF Protection for this example. default is True
23+
authjwt_cookie_csrf_protect: bool = False
24+
25+
@AuthJWT.load_config
26+
def get_config():
27+
return Settings()
28+
29+
@app.exception_handler(AuthJWTException)
30+
def authjwt_exception_handler(request: Request, exc: AuthJWTException):
31+
return JSONResponse(
32+
status_code=exc.status_code,
33+
content={"detail": exc.message}
34+
)
35+
36+
@app.post('/login')
37+
def login(user: User, Authorize: AuthJWT = Depends()):
38+
if user.username != "test" and user.password != "test":
39+
raise HTTPException(status_code=401,detail="Bad username or password")
40+
41+
# Create the tokens and passing to set_access_cookies or set_refresh_cookies
42+
access_token = Authorize.create_access_token(subject=user.username)
43+
refresh_token = Authorize.create_refresh_token(subject=user.username)
44+
45+
# Set the JWT cookies in the response
46+
Authorize.set_access_cookies(access_token)
47+
Authorize.set_refresh_cookies(refresh_token)
48+
return {"msg":"Successfully login"}
49+
50+
@app.post('/refresh')
51+
def refresh(Authorize: AuthJWT = Depends()):
52+
Authorize.jwt_refresh_token_required()
53+
54+
current_user = Authorize.get_jwt_subject()
55+
new_access_token = Authorize.create_access_token(subject=current_user)
56+
# Set the JWT cookies in the response
57+
Authorize.set_access_cookies(new_access_token)
58+
return {"msg":"The token has been refresh"}
59+
60+
@app.delete('/logout')
61+
def logout(Authorize: AuthJWT = Depends()):
62+
"""
63+
Because the JWT are stored in an httponly cookie now, we cannot
64+
log the user out by simply deleting the cookies in the frontend.
65+
We need the backend to send us a response to delete the cookies.
66+
"""
67+
Authorize.jwt_required()
68+
69+
Authorize.unset_jwt_cookies()
70+
return {"msg":"Successfully logout"}
71+
72+
@app.get('/protected')
73+
def protected(Authorize: AuthJWT = Depends()):
74+
"""
75+
We do not need to make any changes to our protected endpoints. They
76+
will all still function the exact same as they do when sending the
77+
JWT in via a headers instead of a cookies
78+
"""
79+
Authorize.jwt_required()
80+
81+
current_user = Authorize.get_jwt_subject()
82+
return {"user": current_user}

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ markdown_extensions:
1515
permalink: true
1616
- pymdownx.highlight:
1717
linenums_style: pymdownx.inline
18+
- attr_list
1819
- def_list
1920
- admonition
2021
- codehilite
@@ -29,6 +30,7 @@ nav:
2930
- Refresh Tokens: usage/refresh.md
3031
- Freshness Tokens: usage/freshness.md
3132
- Revoking Tokens: usage/revoking.md
33+
- JWT in Cookies: usage/jwt_in_cookies.md
3234
- Configuration Options:
3335
- General Options: configuration/general.md
3436
- Headers Options: configuration/headers.md

0 commit comments

Comments
 (0)