diff --git a/modules/httplib/request.go b/modules/httplib/request.go
index 267e276df3..5e40922896 100644
--- a/modules/httplib/request.go
+++ b/modules/httplib/request.go
@@ -8,6 +8,7 @@ import (
 	"bytes"
 	"context"
 	"crypto/tls"
+	"errors"
 	"fmt"
 	"io"
 	"net"
@@ -101,6 +102,9 @@ func (r *Request) Param(key, value string) *Request {
 
 // Body adds request raw body. It supports string, []byte and io.Reader as body.
 func (r *Request) Body(data any) *Request {
+	if r == nil {
+		return nil
+	}
 	switch t := data.(type) {
 	case nil: // do nothing
 	case string:
@@ -193,6 +197,9 @@ func (r *Request) getResponse() (*http.Response, error) {
 // Response executes request client gets response manually.
 // Caller MUST close the response body if no error occurs
 func (r *Request) Response() (*http.Response, error) {
+	if r == nil {
+		return nil, errors.New("invalid request")
+	}
 	return r.getResponse()
 }
 
diff --git a/modules/lfstransfer/backend/backend.go b/modules/lfstransfer/backend/backend.go
index 540932b930..1328d93a48 100644
--- a/modules/lfstransfer/backend/backend.go
+++ b/modules/lfstransfer/backend/backend.go
@@ -70,14 +70,13 @@ func (g *GiteaBackend) Batch(_ string, pointers []transfer.BatchItem, args trans
 		g.logger.Log("json marshal error", err)
 		return nil, err
 	}
-	url := g.server.JoinPath("objects/batch").String()
 	headers := map[string]string{
 		headerAuthorization:     g.authToken,
 		headerGiteaInternalAuth: g.internalAuth,
 		headerAccept:            mimeGitLFS,
 		headerContentType:       mimeGitLFS,
 	}
-	req := newInternalRequestLFS(g.ctx, url, http.MethodPost, headers, bodyBytes)
+	req := newInternalRequestLFS(g.ctx, g.server.JoinPath("objects/batch").String(), http.MethodPost, headers, bodyBytes)
 	resp, err := req.Response()
 	if err != nil {
 		g.logger.Log("http request error", err)
@@ -179,13 +178,12 @@ func (g *GiteaBackend) Download(oid string, args transfer.Args) (io.ReadCloser,
 		g.logger.Log("argument id incorrect")
 		return nil, 0, transfer.ErrCorruptData
 	}
-	url := action.Href
 	headers := map[string]string{
 		headerAuthorization:     g.authToken,
 		headerGiteaInternalAuth: g.internalAuth,
 		headerAccept:            mimeOctetStream,
 	}
-	req := newInternalRequestLFS(g.ctx, url, http.MethodGet, headers, nil)
+	req := newInternalRequestLFS(g.ctx, toInternalLFSURL(action.Href), http.MethodGet, headers, nil)
 	resp, err := req.Response()
 	if err != nil {
 		return nil, 0, fmt.Errorf("failed to get response: %w", err)
@@ -225,7 +223,6 @@ func (g *GiteaBackend) Upload(oid string, size int64, r io.Reader, args transfer
 		g.logger.Log("argument id incorrect")
 		return transfer.ErrCorruptData
 	}
-	url := action.Href
 	headers := map[string]string{
 		headerAuthorization:     g.authToken,
 		headerGiteaInternalAuth: g.internalAuth,
@@ -233,7 +230,7 @@ func (g *GiteaBackend) Upload(oid string, size int64, r io.Reader, args transfer
 		headerContentLength:     strconv.FormatInt(size, 10),
 	}
 
-	req := newInternalRequestLFS(g.ctx, url, http.MethodPut, headers, nil)
+	req := newInternalRequestLFS(g.ctx, toInternalLFSURL(action.Href), http.MethodPut, headers, nil)
 	req.Body(r)
 	resp, err := req.Response()
 	if err != nil {
@@ -274,14 +271,13 @@ func (g *GiteaBackend) Verify(oid string, size int64, args transfer.Args) (trans
 		// the server sent no verify action
 		return transfer.SuccessStatus(), nil
 	}
-	url := action.Href
 	headers := map[string]string{
 		headerAuthorization:     g.authToken,
 		headerGiteaInternalAuth: g.internalAuth,
 		headerAccept:            mimeGitLFS,
 		headerContentType:       mimeGitLFS,
 	}
-	req := newInternalRequestLFS(g.ctx, url, http.MethodPost, headers, bodyBytes)
+	req := newInternalRequestLFS(g.ctx, toInternalLFSURL(action.Href), http.MethodPost, headers, bodyBytes)
 	resp, err := req.Response()
 	if err != nil {
 		return transfer.NewStatus(transfer.StatusInternalServerError), err
diff --git a/modules/lfstransfer/backend/lock.go b/modules/lfstransfer/backend/lock.go
index 4b45658611..639f8b184e 100644
--- a/modules/lfstransfer/backend/lock.go
+++ b/modules/lfstransfer/backend/lock.go
@@ -43,14 +43,13 @@ func (g *giteaLockBackend) Create(path, refname string) (transfer.Lock, error) {
 		g.logger.Log("json marshal error", err)
 		return nil, err
 	}
-	url := g.server.String()
 	headers := map[string]string{
 		headerAuthorization:     g.authToken,
 		headerGiteaInternalAuth: g.internalAuth,
 		headerAccept:            mimeGitLFS,
 		headerContentType:       mimeGitLFS,
 	}
-	req := newInternalRequestLFS(g.ctx, url, http.MethodPost, headers, bodyBytes)
+	req := newInternalRequestLFS(g.ctx, g.server.String(), http.MethodPost, headers, bodyBytes)
 	resp, err := req.Response()
 	if err != nil {
 		g.logger.Log("http request error", err)
@@ -95,14 +94,13 @@ func (g *giteaLockBackend) Unlock(lock transfer.Lock) error {
 		g.logger.Log("json marshal error", err)
 		return err
 	}
-	url := g.server.JoinPath(lock.ID(), "unlock").String()
 	headers := map[string]string{
 		headerAuthorization:     g.authToken,
 		headerGiteaInternalAuth: g.internalAuth,
 		headerAccept:            mimeGitLFS,
 		headerContentType:       mimeGitLFS,
 	}
-	req := newInternalRequestLFS(g.ctx, url, http.MethodPost, headers, bodyBytes)
+	req := newInternalRequestLFS(g.ctx, g.server.JoinPath(lock.ID(), "unlock").String(), http.MethodPost, headers, bodyBytes)
 	resp, err := req.Response()
 	if err != nil {
 		g.logger.Log("http request error", err)
@@ -176,16 +174,15 @@ func (g *giteaLockBackend) Range(cursor string, limit int, iter func(transfer.Lo
 }
 
 func (g *giteaLockBackend) queryLocks(v url.Values) ([]transfer.Lock, string, error) {
-	urlq := g.server.JoinPath() // get a copy
-	urlq.RawQuery = v.Encode()
-	url := urlq.String()
+	serverURLWithQuery := g.server.JoinPath() // get a copy
+	serverURLWithQuery.RawQuery = v.Encode()
 	headers := map[string]string{
 		headerAuthorization:     g.authToken,
 		headerGiteaInternalAuth: g.internalAuth,
 		headerAccept:            mimeGitLFS,
 		headerContentType:       mimeGitLFS,
 	}
-	req := newInternalRequestLFS(g.ctx, url, http.MethodGet, headers, nil)
+	req := newInternalRequestLFS(g.ctx, serverURLWithQuery.String(), http.MethodGet, headers, nil)
 	resp, err := req.Response()
 	if err != nil {
 		g.logger.Log("http request error", err)
diff --git a/modules/lfstransfer/backend/util.go b/modules/lfstransfer/backend/util.go
index f322d54257..98ce0b1e62 100644
--- a/modules/lfstransfer/backend/util.go
+++ b/modules/lfstransfer/backend/util.go
@@ -8,9 +8,13 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
+	"strings"
 
 	"code.gitea.io/gitea/modules/httplib"
 	"code.gitea.io/gitea/modules/private"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/util"
 
 	"github.com/charmbracelet/git-lfs-transfer/transfer"
 )
@@ -57,8 +61,7 @@ const (
 
 // Operations enum
 const (
-	opNone = iota
-	opDownload
+	opDownload = iota + 1
 	opUpload
 )
 
@@ -86,8 +89,49 @@ func statusCodeToErr(code int) error {
 	}
 }
 
-func newInternalRequestLFS(ctx context.Context, url, method string, headers map[string]string, body any) *httplib.Request {
-	req := private.NewInternalRequest(ctx, url, method)
+func toInternalLFSURL(s string) string {
+	pos1 := strings.Index(s, "://")
+	if pos1 == -1 {
+		return ""
+	}
+	appSubURLWithSlash := setting.AppSubURL + "/"
+	pos2 := strings.Index(s[pos1+3:], appSubURLWithSlash)
+	if pos2 == -1 {
+		return ""
+	}
+	routePath := s[pos1+3+pos2+len(appSubURLWithSlash):]
+	fields := strings.SplitN(routePath, "/", 3)
+	if len(fields) < 3 || !strings.HasPrefix(fields[2], "info/lfs") {
+		return ""
+	}
+	return setting.LocalURL + "api/internal/repo/" + routePath
+}
+
+func isInternalLFSURL(s string) bool {
+	if !strings.HasPrefix(s, setting.LocalURL) {
+		return false
+	}
+	u, err := url.Parse(s)
+	if err != nil {
+		return false
+	}
+	routePath := util.PathJoinRelX(u.Path)
+	subRoutePath, cut := strings.CutPrefix(routePath, "api/internal/repo/")
+	if !cut {
+		return false
+	}
+	fields := strings.SplitN(subRoutePath, "/", 3)
+	if len(fields) < 3 || !strings.HasPrefix(fields[2], "info/lfs") {
+		return false
+	}
+	return true
+}
+
+func newInternalRequestLFS(ctx context.Context, internalURL, method string, headers map[string]string, body any) *httplib.Request {
+	if !isInternalLFSURL(internalURL) {
+		return nil
+	}
+	req := private.NewInternalRequest(ctx, internalURL, method)
 	for k, v := range headers {
 		req.Header(k, v)
 	}
diff --git a/modules/lfstransfer/backend/util_test.go b/modules/lfstransfer/backend/util_test.go
new file mode 100644
index 0000000000..408b53c369
--- /dev/null
+++ b/modules/lfstransfer/backend/util_test.go
@@ -0,0 +1,53 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package backend
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/test"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestToInternalLFSURL(t *testing.T) {
+	defer test.MockVariableValue(&setting.LocalURL, "http://localurl/")()
+	defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
+	cases := []struct {
+		url      string
+		expected string
+	}{
+		{"http://appurl/any", ""},
+		{"http://appurl/sub/any", ""},
+		{"http://appurl/sub/owner/repo/any", ""},
+		{"http://appurl/sub/owner/repo/info/any", ""},
+		{"http://appurl/sub/owner/repo/info/lfs/any", "http://localurl/api/internal/repo/owner/repo/info/lfs/any"},
+	}
+	for _, c := range cases {
+		assert.Equal(t, c.expected, toInternalLFSURL(c.url), c.url)
+	}
+}
+
+func TestIsInternalLFSURL(t *testing.T) {
+	defer test.MockVariableValue(&setting.LocalURL, "http://localurl/")()
+	defer test.MockVariableValue(&setting.InternalToken, "mock-token")()
+	cases := []struct {
+		url      string
+		expected bool
+	}{
+		{"", false},
+		{"http://otherurl/api/internal/repo/owner/repo/info/lfs/any", false},
+		{"http://localurl/api/internal/repo/owner/repo/info/lfs/any", true},
+		{"http://localurl/api/internal/repo/owner/repo/info", false},
+		{"http://localurl/api/internal/misc/owner/repo/info/lfs/any", false},
+		{"http://localurl/api/internal/owner/repo/info/lfs/any", false},
+		{"http://localurl/api/internal/foo/bar", false},
+	}
+	for _, c := range cases {
+		req := newInternalRequestLFS(t.Context(), c.url, "GET", nil, nil)
+		assert.Equal(t, c.expected, req != nil, c.url)
+		assert.Equal(t, c.expected, isInternalLFSURL(c.url), c.url)
+	}
+}
diff --git a/modules/private/internal.go b/modules/private/internal.go
index 3bd4eb06b1..35eed1d608 100644
--- a/modules/private/internal.go
+++ b/modules/private/internal.go
@@ -40,6 +40,10 @@ func NewInternalRequest(ctx context.Context, url, method string) *httplib.Reques
 Ensure you are running in the correct environment or set the correct configuration file with -c.`, setting.CustomConf)
 	}
 
+	if !strings.HasPrefix(url, setting.LocalURL) {
+		log.Fatal("Invalid internal request URL: %q", url)
+	}
+
 	req := httplib.NewRequest(url, method).
 		SetContext(ctx).
 		Header("X-Real-IP", getClientIP()).
diff --git a/tests/integration/git_lfs_ssh_test.go b/tests/integration/git_lfs_ssh_test.go
index 66c1d1fe5b..64a403f513 100644
--- a/tests/integration/git_lfs_ssh_test.go
+++ b/tests/integration/git_lfs_ssh_test.go
@@ -54,9 +54,14 @@ func TestGitLFSSSH(t *testing.T) {
 			return strings.Contains(s, "POST /api/internal/repo/user2/repo1.git/info/lfs/objects/batch")
 		})
 		countUpload := slices.ContainsFunc(routerCalls, func(s string) bool {
-			return strings.Contains(s, "PUT /user2/repo1.git/info/lfs/objects/")
+			return strings.Contains(s, "PUT /api/internal/repo/user2/repo1.git/info/lfs/objects/")
+		})
+		nonAPIRequests := slices.ContainsFunc(routerCalls, func(s string) bool {
+			fields := strings.Fields(s)
+			return !strings.HasPrefix(fields[1], "/api/")
 		})
 		assert.NotZero(t, countBatch)
 		assert.NotZero(t, countUpload)
+		assert.Zero(t, nonAPIRequests)
 	})
 }
diff --git a/tests/mssql.ini.tmpl b/tests/mssql.ini.tmpl
index ffba516ed3..42bf683a07 100644
--- a/tests/mssql.ini.tmpl
+++ b/tests/mssql.ini.tmpl
@@ -45,6 +45,7 @@ SIGNING_KEY = none
 SSH_DOMAIN       = localhost
 HTTP_PORT        = 3003
 ROOT_URL         = http://localhost:3003/
+LOCAL_ROOT_URL   = http://127.0.0.1:3003/
 DISABLE_SSH      = false
 SSH_LISTEN_HOST  = localhost
 SSH_PORT         = 2201
diff --git a/tests/mysql.ini.tmpl b/tests/mysql.ini.tmpl
index e2f2e1390a..7cef540d1d 100644
--- a/tests/mysql.ini.tmpl
+++ b/tests/mysql.ini.tmpl
@@ -47,6 +47,7 @@ SIGNING_KEY = none
 SSH_DOMAIN       = localhost
 HTTP_PORT        = 3001
 ROOT_URL         = http://localhost:3001/
+LOCAL_ROOT_URL   = http://127.0.0.1:3001/
 DISABLE_SSH      = false
 SSH_LISTEN_HOST  = localhost
 SSH_PORT         = 2201
diff --git a/tests/pgsql.ini.tmpl b/tests/pgsql.ini.tmpl
index 483b9ed0cd..13a5932608 100644
--- a/tests/pgsql.ini.tmpl
+++ b/tests/pgsql.ini.tmpl
@@ -46,6 +46,7 @@ SIGNING_KEY = none
 SSH_DOMAIN       = localhost
 HTTP_PORT        = 3002
 ROOT_URL         = http://localhost:3002/
+LOCAL_ROOT_URL   = http://127.0.0.1:3002/
 DISABLE_SSH      = false
 SSH_LISTEN_HOST  = localhost
 SSH_PORT         = 2202
diff --git a/tests/sqlite.ini.tmpl b/tests/sqlite.ini.tmpl
index e837860c26..938f203633 100644
--- a/tests/sqlite.ini.tmpl
+++ b/tests/sqlite.ini.tmpl
@@ -41,6 +41,7 @@ SIGNING_KEY = none
 SSH_DOMAIN       = localhost
 HTTP_PORT        = 3003
 ROOT_URL         = http://localhost:3003/
+LOCAL_ROOT_URL   = http://127.0.0.1:3003/
 DISABLE_SSH      = false
 SSH_LISTEN_HOST  = localhost
 SSH_PORT         = 2203