# How to Implement Cookie-Based Refresh Tokens in FastAPI: A Complete Guide

> Learn to implement cookie-based refresh tokens in FastAPI using HttpOnly, Secure cookies. This guide enables stateless JWT renewal and helps mitigate XSS attacks.

- Repository: [Benav Labs/fastapi-boilerplate](https://github.com/benavlabs/fastapi-boilerplate)
- Tags: how-to-guide
- Published: 2026-02-26

---

**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`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/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`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/src/app/core/security.py), the `create_refresh_token` function generates long-lived JWTs with a specific token type claim:

```python
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`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/src/app/api/v1/login.py) sets the refresh token as a secure cookie immediately after authentication:

```python
@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](https://github.com/benavlabs/fastapi-boilerplate/blob/main/src/app/api/v1/login.py#L24-L44)

## 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.

In [`src/app/api/v1/login.py`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/src/app/api/v1/login.py):

```python
@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](https://github.com/benavlabs/fastapi-boilerplate/blob/main/src/app/api/v1/login.py#L47-L58)

The `verify_token` function in [`src/app/core/security.py`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/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.

In [`src/app/api/v1/logout.py`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/src/app/api/v1/logout.py):

```python
@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](https://github.com/benavlabs/fastapi-boilerplate/blob/main/src/app/api/v1/logout.py#L14-L27)

The `blacklist_tokens` function in [`src/app/core/security.py`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/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`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/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`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/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 **`TokenType`** enum in [`src/app/core/security.py`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/src/app/core/security.py) to distinguish access and refresh tokens.
- Implement **`create_refresh_token`** and **`verify_token`** with database-backed blacklisting to handle revocation.
- Set cookies in the **login endpoint** using `response.set_cookie` with 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`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/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`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/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`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/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`](https://github.com/benavlabs/fastapi-boilerplate/blob/main/src/app/core/security.py) handles this granular revocation by extracting and storing each token's unique jti separately.