I’m building an admin panel with FastAPI using sqladmin, and I’ve implemented a custom authentication backend by extending sqladmin’s AuthenticationBackend. In my login method, when a POST request is made to /admin/login, I validate the credentials, create a JWT token with the claim is_admin=True, and then set this token as a cookie named "admin_token". The response is a RedirectResponse (HTTP 303) that redirects the user to the admin index page.
However, while testing in Chrome and Safari, I found that the "admin_token" cookie is not being stored by the browser when returned from this POST redirect response. Interestingly, when I set the same cookie in a GET request (using a test endpoint), it gets stored correctly.
Here’s the relevant code:
admin_auth_service.py:
import logging
import jwt
from fastapi import Request, status
from sqladmin.authentication import AuthenticationBackend
from starlette.responses import RedirectResponse, Response
from app.core.config import settings
from app.db.base import async_db_cntxman
from app.schemas.admin_schema import AdminLoginRequest
from app.services.admin_service import AdminService
logger = logging.getLogger("app")
class AdminAuthService(AuthenticationBackend):
async def login(self, request: Request) -> bool | Response:
if request.method != "POST":
return False # GET on /admin/login shows the login form
response = RedirectResponse(
url=request.url_for("admin:index"), status_code=status.HTTP_303_SEE_OTHER
)
form_data = dict(await request.form())
try:
async with async_db_cntxman() as db:
form_admin_req = AdminLoginRequest.model_validate(form_data)
data = await AdminService.login(db, form_admin_req)
response.set_cookie(
key="admin_token",
value=data.access_token,
httponly=True, # Protects against XSS
secure=False, # False for local development (HTTP)
samesite="lax", # Helps mitigate CSRF issues
max_age=3600, # 1 hour session duration
path="/", # Valid for the entire admin interface
)
logger.info("Login successful.")
return response
except Exception as e:
logger.error(f"Login error: {e!s}")
return False
async def logout(self, request: Request) -> bool | Response:
response = RedirectResponse(
url=request.url_for("admin:login"), status_code=status.HTTP_302_FOUND
)
response.delete_cookie("admin_token")
return response
async def authenticate(self, request: Request) -> Response | None:
token = request.cookies.get("admin_token")
if not token:
return RedirectResponse(
request.url_for("admin:login"), status_code=status.HTTP_302_FOUND
)
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
if not payload.get("is_admin"):
return RedirectResponse(
request.url_for("admin:login"), status_code=status.HTTP_302_FOUND
)
except jwt.ExpiredSignatureError:
logger.warning("Admin token expired.")
return RedirectResponse(
request.url_for("admin:login"), status_code=status.HTTP_302_FOUND
)
except jwt.PyJWTError as e:
logger.warning(f"JWT error in admin token: {e}")
return RedirectResponse(
request.url_for("admin:login"), status_code=status.HTTP_302_FOUND
)
async with async_db_cntxman() as db:
admin = await AdminService.is_admin(db, payload.get("sub"))
if not admin:
return RedirectResponse(
request.url_for("admin:login"), status_code=status.HTTP_302_FOUND
)
return None
temp_router.py (Test endpoint that successfully sets a cookie):
from fastapi import APIRouter, Response
from app.utils.response import success_response
router = APIRouter()
@router.get("/setcookie")
async def set_cookie(response: Response):
response.set_cookie(key="test_cookie", value="cookie_value")
response.set_cookie(
key="admin_token",
value="data.access_token",
httponly=True,
secure=False,
samesite="lax",
max_age=3600,
path="/",
)
response.headers["X-Debug"] = "Cookie-Test"
return success_response("Cookie set successfully")
@router.post("/postcookie")
async def post_cookie(request: Request):
response = RedirectResponse(url=request.url_for("redirect"), status_code=303)
response.set_cookie(
key="post_cookie",
value="postcookie",
httponly=True,
secure=False,
samesite="lax",
max_age=3600,
path="/",
)
return response
@router.get("/redirect", name="redirect")
async def redirect(request: Request):
cookie = request.cookies.get("post_cookie")
if cookie:
return success_response("Redirected successfully", data={"cookie": cookie})
else:
return error_response("No postcookie found", code=404, detail="Cookie not found")
The test endpoint (/setcookie) sets the "admin_token" cookie successfully, but in the login flow, the cookie isn’t stored, and subsequent requests redirect back to the login page due to missing authentication.
My questions are: 1. Why isn’t the "admin_token" cookie being stored when it is set in the POST redirect response? 2. Is this issue caused by browser security policies related to setting cookies on redirect responses, or is there something in my code that I should adjust? 3. What are the best practices for setting authentication cookies in FastAPI to ensure they are reliably stored across redirects?
Any help or insights would be greatly appreciated. Please let me know if you need further details.
I tried several approaches to have the “admin_token” cookie persist after a POST login request that results in a redirect. Specifically, I: • Returned a RedirectResponse with status code 303 (See Other) and then with 302 (Found). • Set the cookie using different SameSite values: “None”, “Lax”, and “Strict”. • Tested setting the domain attribute (e.g., domain=“localhost”) and omitting it entirely.
In every case—regardless of the combination of SameSite (None, Lax, Strict) and whether the domain was set or not—the cookie was not stored by the browser when the POST request triggered the redirect. I expected that the browser would accept and persist the cookie so that it would be included in subsequent requests, but it never happened either on Safari and Chrome.
EDIT
below add httpie output:
here for the temp router
$ http --session=./zzzz/cookie_jar POST http://localhost:8000/api/v1/temp/postcookie --follow -v
POST /api/v1/temp/postcookie HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 0
Host: localhost:8000
User-Agent: HTTPie/3.2.4
HTTP/1.1 303 See Other
content-length: 0
date: Mon, 17 Mar 2025 15:06:57 GMT
location: http://localhost:8000/api/v1/temp/redirect
referrer-policy: strict-origin
server: uvicorn
set-cookie: post_cookie=postcookie; HttpOnly; Max-Age=3600; Path=/; SameSite=lax
x-content-type-options: nosniff
x-frame-options: DENY
GET /api/v1/temp/redirect HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: post_cookie=postcookie
Host: localhost:8000
User-Agent: HTTPie/3.2.4
HTTP/1.1 200 OK
content-length: 134
content-type: application/json
date: Mon, 17 Mar 2025 15:06:57 GMT
referrer-policy: strict-origin
server: uvicorn
x-content-type-options: nosniff
x-frame-options: DENY
{
"data": {
"cookie": "postcookie"
},
"message": "Redirected successfully",
"status": "success",
"timestamp": "2025-03-17T15:06:57.812173+00:00"
}
here for sqladmin login
$ http --session=./zzzz/cookie_jar -f POST http://localhost:8000/admin/login username=admin password=Admin00! --follow -v --print=HhBb
POST /admin/login HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 34
Content-Type: application/x-www-form-urlencoded; charset=utf-8
Host: localhost:8000
User-Agent: HTTPie/3.2.4
username=admin&password=Admin00%21
HTTP/1.1 302 Found
content-length: 0
date: Mon, 17 Mar 2025 15:30:33 GMT
location: http://localhost:8000/admin/
referrer-policy: strict-origin
server: uvicorn
x-content-type-options: nosniff
x-frame-options: DENY
GET /admin/ HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Host: localhost:8000
User-Agent: HTTPie/3.2.4
HTTP/1.1 302 Found
content-length: 0
date: Mon, 17 Mar 2025 15:30:33 GMT
location: http://localhost:8000/admin/login
referrer-policy: strict-origin
server: uvicorn
x-content-type-options: nosniff
x-frame-options: DENY
GET /admin/login HTTP/1.1
Accept: application/json, */*;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Host: localhost:8000
User-Agent: HTTPie/3.2.4
HTTP/1.1 200 OK
content-length: 2457
content-type: text/html; charset=utf-8
date: Mon, 17 Mar 2025 15:30:33 GMT
referrer-policy: strict-origin
server: uvicorn
x-content-type-options: nosniff
x-frame-options: DENY
<!DOCTYPE html>
<html lang="en">
...
</html>