How to Implement Cookie-Based Refresh Tokens in FastAPI: A Complete Guide
Store refresh tokens in HttpOnly, Secure cookies and validate them against a database blacklist to enable stateless JWT renewal while mitigating XSS attacks.
Implementing secure authentication in FastAPI requires careful handling of refresh tokens to balance security and user experience. The benavlabs/fastapi-boilerplate repository demonstrates a production-ready approach to cookie-based refresh tokens in FastAPI, storing long-lived credentials in browser cookies while keeping them inaccessible to JavaScript. This guide walks through the complete implementation, from token creation to secure revocation.
Architecture Overview
The implementation relies on a clear separation between short-lived access tokens and long-lived refresh tokens stored in cookies.
| Component | Role | Implementation |
|---|---|---|
TokenType enum |
Distinguishes access vs. refresh tokens | Defined in src/app/core/security.py – TokenType.REFRESH is used when creating refresh tokens. |
| Token creation | Generates signed JWTs with expiration & type claim | create_access_token (minutes) and create_refresh_token (days) embed "token_type" in the payload. |
| Login endpoint | Authenticates user, returns access token & sets refresh cookie | After successful auth, create_refresh_token is called and the token is sent via Response.set_cookie. |
| Refresh endpoint | Reads the refresh cookie, validates it, issues a new access token | Retrieves cookie via request.cookies.get("refresh_token"), calls verify_token(..., TokenType.REFRESH, ...). |
| Logout endpoint | Revokes both tokens and removes the cookie | Calls blacklist_tokens to store token IDs in DB and deletes the cookie with Response.delete_cookie. |
| Token blacklisting | Prevents reuse of revoked tokens | blacklist_tokens decodes the token, extracts its expiry and stores it in token_blacklist table. The verify_token function checks this table first. |
Creating and Storing Refresh Tokens
Token Generation
In src/app/core/security.py, the create_refresh_token function generates long-lived JWTs with a specific token type claim:
class TokenType(str, Enum):
ACCESS = "access"
REFRESH = "refresh"
async def create_refresh_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
# ... calculates expiry based on REFRESH_TOKEN_EXPIRE_DAYS
to_encode.update({"exp": expire, "token_type": TokenType.REFRESH})
return jwt.encode(to_encode, SECRET_KEY.get_secret_value(), algorithm=ALGORITHM)
Setting the HttpOnly Cookie
The login endpoint in src/app/api/v1/login.py sets the refresh token as a secure cookie immediately after authentication:
@router.post("/login", response_model=Token)
async def login_for_access_token(
response: Response,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[AsyncSession, Depends(async_get_db)],
) -> dict[str, str]:
user = await authenticate_user(
username_or_email=form_data.username,
password=form_data.password,
db=db,
)
if not user:
raise UnauthorizedException("Wrong username, email or password.")
# Access token (short‑lived)
access_token = await create_access_token(
data={"sub": user["username"]},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
# Refresh token (long‑lived)
refresh_token = await create_refresh_token(data={"sub": user["username"]})
max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
# Store refresh token in a secure HttpOnly cookie
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
samesite="lax",
max_age=max_age,
)
return {"access_token": access_token, "token_type": "bearer"}
Source: login.py line 24‑44
Validating and Refreshing Access Tokens
When the access token expires, the client calls the refresh endpoint. The server extracts the token from the cookie, validates it against the database blacklist, and issues a new access token.
@router.post("/refresh")
async def refresh_access_token(
request: Request,
db: AsyncSession = Depends(async_get_db),
) -> dict[str, str]:
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise UnauthorizedException("Refresh token missing.")
user_data = await verify_token(refresh_token, TokenType.REFRESH, db)
if not user_data:
raise UnauthorizedException("Invalid refresh token.")
new_access_token = await create_access_token(
data={"sub": user_data.username_or_email}
)
return {"access_token": new_access_token, "token_type": "bearer"}
Source: login.py line 47‑58
The verify_token function in src/app/core/security.py first checks the token_blacklist table before decoding the JWT, ensuring revoked tokens cannot be reused.
Secure Logout and Token Revocation
To prevent replay attacks, the logout endpoint explicitly blacklists both tokens and instructs the browser to delete the refresh cookie.
@router.post("/logout")
async def logout(
response: Response,
access_token: str = Depends(oauth2_scheme),
refresh_token: Optional[str] = Cookie(None, alias="refresh_token"),
db: AsyncSession = Depends(async_get_db),
) -> dict[str, str]:
if not refresh_token:
raise UnauthorizedException("Refresh token not found")
# Store tokens in blacklist so they cannot be reused
await blacklist_tokens(
access_token=access_token,
refresh_token=refresh_token,
db=db,
)
# Remove the cookie from the client
response.delete_cookie(key="refresh_token")
return {"message": "Logged out successfully"}
Source: logout.py line 14‑27
The blacklist_tokens function in src/app/core/security.py decodes the tokens, extracts their expiry dates, and persists them to the token_blacklist table via src/app/core/db/crud_token_blacklist.py.
Security Configuration Best Practices
The implementation follows OWASP guidelines for cookie-based authentication:
- HttpOnly: Prevents JavaScript access via
document.cookie, mitigating XSS attacks. - Secure: Ensures cookies are only transmitted over HTTPS.
- SameSite=Lax: Allows cookies to be sent with top-level navigation requests while blocking cross-site POST requests, reducing CSRF risk.
- Max-Age: Aligns cookie lifetime with the refresh token's JWT expiry.
These settings are configured in src/app/core/config.py and applied consistently across the authentication flow.
Summary
- Store refresh tokens in HttpOnly, Secure, SameSite=Lax cookies to prevent XSS and CSRF attacks.
- Use a
TokenTypeenum insrc/app/core/security.pyto distinguish access and refresh tokens. - Implement
create_refresh_tokenandverify_tokenwith database-backed blacklisting to handle revocation. - Set cookies in the login endpoint using
response.set_cookiewith security flags. - Retrieve and validate cookies in the refresh endpoint using
request.cookies.get. - Explicitly blacklist tokens and delete cookies during logout to prevent replay attacks.
Frequently Asked Questions
Why store refresh tokens in cookies instead of localStorage?
Storing refresh tokens in HttpOnly cookies prevents JavaScript access, eliminating XSS attack vectors that could steal long-lived credentials. LocalStorage is accessible via document.localStorage, making it vulnerable to malicious scripts. Cookies with the Secure and SameSite attributes also provide built-in CSRF protection mechanisms that localStorage lacks.
How does the blacklist prevent token reuse after logout?
When a user logs out, the blacklist_tokens function in src/app/core/security.py decodes both the access and refresh tokens, extracts their unique identifiers (jti) and expiry timestamps, and persists them to the token_blacklist table via src/app/core/db/crud_token_blacklist.py. On every token verification, verify_token queries this table first, rejecting any token whose jti exists in the blacklist, even if the JWT signature is valid and the expiry date has not passed.
What is the difference between the access token and refresh token in this implementation?
The access token is a short-lived JWT (typically minutes) returned in the JSON response body, used to authenticate API requests via the Authorization header. The refresh token is a long-lived JWT (typically days) stored in an HttpOnly cookie, used exclusively to obtain new access tokens when the current one expires. The TokenType enum in src/app/core/security.py ensures these tokens are not interchangeable by embedding a "token_type" claim in each JWT payload.
Can this implementation handle multiple concurrent sessions per user?
Yes, because each token pair (access and refresh) has a unique identifier (jti) generated during creation. The blacklist stores individual token identifiers rather than user IDs, allowing a user to have multiple active sessions across different devices. When logging out from one device, only the specific refresh token from that device's cookie is blacklisted, leaving other sessions unaffected. The blacklist_tokens function in src/app/core/security.py handles this granular revocation by extracting and storing each token's unique jti separately.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →