[Python] Django OAuth2: HttpOnly refresh token cookie not getting through to browser (but...

Discussão em 'Python' iniciado por Stack, Julho 13, 2025.

  1. Stack

    Stack Membro Participativo

    I implemented Google OAuth2 login in Django (with PKCE) with Django OAuth Toolkit. After login, the server creates an access_token (in the response body) and a refresh_token, which is stored in an HttpOnly cookie:

    @api_view(["POST"])
    def google_login(request):
    code = request.data.get("code")
    if code:
    code = unquote(code)
    code_verifier = request.data.get("code_verifier")

    if not code or not code_verifier:
    return Response({"error": "Missing code or code_verifier"}, status=400)

    # Google token exchange
    token_resp = requests.post("https://oauth2.googleapis.com/token", data={
    "client_id": GOOGLE_CLIENT_ID,
    "client_secret": GOOGLE_CLIENT_SECRET,
    "code": code,
    "code_verifier": code_verifier,
    "grant_type": "authorization_code",
    "redirect_uri": REDIRECT_URI,
    }).json()

    if "error" in token_resp:
    return Response({"error": "Google exchange failed",
    "details": token_resp}, status=400)

    access_token = token_resp.get("access_token")

    # Retrieving and storing user information
    userinfo = requests.get("https://openidconnect.googleapis.com/v1/userinfo",
    headers={"Authorization": f"Bearer {access_token}"}).json()

    email = userinfo.get("email")
    if not email:
    return Response({"error": "No email returned by Google",
    "userinfo": userinfo}, status=400)

    user, _ = User.objects.get_or_create(email=email, defaults={
    "username": email,
    "first_name": userinfo.get("given_name", ""),
    "last_name": userinfo.get("family_name", ""),
    "avatar": userinfo.get("picture", ""),
    })
    assign_user_role(user)

    # token generation
    app = Application.objects.get(name="React SPA")
    expires = timezone.now() + timedelta(seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS)

    access_token_obj = AccessToken.objects.create(
    user=user,
    application=app,
    token=generate_token(),
    expires=expires,
    scope="read write"
    )

    refresh_token_obj = RefreshToken.objects.create(
    user=user,
    token=generate_token(),
    application=app,
    access_token=access_token_obj
    )

    response = Response({
    "access_token": access_token_obj.token,
    "expires_in": oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS,
    "token_type": "Bearer"
    })

    response.set_cookie(
    "refresh_token",
    refresh_token_obj.token,
    httponly=True,
    secure=False, # True on prod
    samesite="Lax",
    path="/",
    )

    return response


    I also implemented a custom token endpoint (CustomTokenView) that supports refresh by cookie (and not through the request body), that is:

    @method_decorator(csrf_exempt, name='dispatch')
    class CustomTokenView(OAuthLibMixin, APIView):
    def post(self, request, *args, **kwargs):
    if request.data.get("grant_type") == "refresh_token":
    refresh_token_cookie = request.COOKIES.get("refresh_token")
    if refresh_token_cookie:
    post = request._request.POST.copy()
    post["refresh_token"] = refresh_token_cookie
    request._request.POST = post

    url, headers, body, status = self.create_token_response(request._request)

    token_data = json.loads(body)

    if status == 200:
    access_token = token_data.get("access_token")
    refresh_token = token_data.get("refresh_token")

    if access_token:
    token_checksum = hashlib.sha256(access_token.encode("utf-8")).hexdigest()
    token = get_access_token_model().objects.get(token_checksum=token_checksum)
    app_authorized.send(sender=self, request=request, token=token)

    response_data = {
    "access_token": access_token,
    "expires_in": token_data.get("expires_in", oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS),
    "token_type": token_data.get("token_type", "Bearer"),
    }

    response = Response(response_data, status=status)

    if refresh_token:
    response.set_cookie(
    "refresh_token",
    refresh_token,
    httponly=True,
    secure=False, # Local
    samesite="Lax",
    path="/",
    )

    return response

    return HttpResponse(content=body, status=status, headers=headers)


    My urls:

    v1_patterns = [
    ...
    path('auth/authorize/', oauth2_views.AuthorizationView.as_view(), name="authorize"),
    path('auth/token/', CustomTokenView.as_view(), name="token"),
    path("auth/google/", google_login, name='google_login'),
    ]

    urlpatterns = [
    ...
    path("api/v1/", include(v1_patterns)),
    ]


    Problem: In Postman, the refresh_token cookie is set without any problems. But in the browser after login, the cookie does not appear - although the access token arrives. That i have alredy checked: In the browser (React, http://127.0.0.1:5173) I make a POST to the backend (http://127.0.0.1:8000/api/v1/auth/google/)

    await fetch("http://127.0.0.1:8000/api/v1/auth/google/", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ code, code_verifier }),
    });


    CORS in settings:

    CORS_ALLOWED_ORIGINS = [
    "http://127.0.0.1:5173",
    ]
    CORS_ALLOW_CREDENTIALS = True


    So, my question: Why doesn't the browser save the refresh_token cookie after the request, even though it does in Postman? I'm using HttpOnly, SameSite=Lax, secure=False, credentials: 'include', and the correct CORS headers.

    Are there any other conditions under which the browser will ignore Set-Cookie?

    I use docker-compose if it matters!

    I'd appreciate any help!

    Continue reading...

Compartilhe esta Página