diff --git a/pkg/routes/api/v2/label_task_bulk.go b/pkg/routes/api/v2/label_task_bulk.go
new file mode 100644
index 000000000..82f837540
--- /dev/null
+++ b/pkg/routes/api/v2/label_task_bulk.go
@@ -0,0 +1,62 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// RegisterLabelTaskBulkRoutes wires the bulk label-replacement action onto the
+// Huma API. The model op is a CRUDable Create (handler.DoCreate, whose
+// CanCreate enforces write access to the task), but the verb is PUT because the
+// operation replaces the task's whole label set — the idempotent PUT semantics
+// describe it more honestly than POST.
+func RegisterLabelTaskBulkRoutes(api huma.API) {
+ tags := []string{"labels"}
+
+ Register(api, huma.Operation{
+ OperationID: "task-labels-bulk-replace",
+ Summary: "Replace all labels on a task",
+ Description: "Sets the task's labels to exactly the provided list: labels not in the list are removed, missing ones are added, unchanged ones are left alone. Requires write access to the task, and you must be able to see every label you attach. Returns the resulting label set.",
+ Method: http.MethodPut,
+ Path: "/tasks/{projecttask}/labels/bulk",
+ Tags: tags,
+ }, labelTasksBulkReplace)
+}
+
+func init() { AddRouteRegistrar(RegisterLabelTaskBulkRoutes) }
+
+func labelTasksBulkReplace(ctx context.Context, in *struct {
+ TaskID int64 `path:"projecttask" doc:"The numeric id of the task whose labels to replace."`
+ Body models.LabelTaskBulk
+}) (*singleBody[models.LabelTaskBulk], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ in.Body.TaskID = in.TaskID // parent from the path, not the body
+ if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.LabelTaskBulk]{Body: &in.Body}, nil
+}
diff --git a/pkg/webtests/huma_label_task_bulk_test.go b/pkg/webtests/huma_label_task_bulk_test.go
new file mode 100644
index 000000000..3ee42e45f
--- /dev/null
+++ b/pkg/webtests/huma_label_task_bulk_test.go
@@ -0,0 +1,117 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package webtests
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestLabelTaskBulk_V2 ports the v1 bulk-replace matrix
+// (pkg/webtests/label_task_test.go) onto PUT /api/v2/tasks/{projecttask}/labels/bulk.
+// The body is the full target label set; the call adds missing labels and
+// removes any not listed.
+//
+// Permission topology for testuser1 (see pkg/db/fixtures):
+// - task 1 (project 1): owned by user1 → write. Has label #4 attached.
+// - task 15 (project 6): shared via team 2 read-only → no write.
+// - task 16 (project 7): shared via team 3 with write.
+// - task 34 (project 20): private to user13 → no access.
+//
+// Labels: #1 own; #3 (user2, attached to no visible task) is invisible to user1.
+func TestLabelTaskBulk_V2(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ put := func(taskID, body string) (*v2ProblemJSON, []int64, int) {
+ t.Helper()
+ rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/"+taskID+"/labels/bulk", body, token, "")
+ if rec.Code >= 400 {
+ var p v2ProblemJSON
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &p), "error body: %s", rec.Body.String())
+ return &p, nil, rec.Code
+ }
+ var resp struct {
+ Labels []struct {
+ ID int64 `json:"id"`
+ } `json:"labels"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp), "body: %s", rec.Body.String())
+ ids := make([]int64, 0, len(resp.Labels))
+ for _, l := range resp.Labels {
+ ids = append(ids, l.ID)
+ }
+ return nil, ids, rec.Code
+ }
+
+ t.Run("Replace adds and removes", func(t *testing.T) {
+ // task 1 starts with label #4; replacing with [#1] must add #1 and drop #4.
+ p, ids, code := put("1", `{"labels":[{"id":1}]}`)
+ require.Nil(t, p)
+ assert.Equal(t, http.StatusOK, code)
+ assert.ElementsMatch(t, []int64{1}, ids,
+ "task 1's labels must be exactly {1} after replace")
+ })
+ t.Run("Empty list clears all labels", func(t *testing.T) {
+ // task 16 (write-shared) gets a label, then an empty replace removes it.
+ _, ids, code := put("16", `{"labels":[{"id":1}]}`)
+ assert.Equal(t, http.StatusOK, code)
+ assert.ElementsMatch(t, []int64{1}, ids)
+
+ p, ids, code := put("16", `{"labels":[]}`)
+ require.Nil(t, p)
+ assert.Equal(t, http.StatusOK, code)
+ assert.Empty(t, ids, "empty replace must remove every label")
+ })
+ t.Run("Write share can replace", func(t *testing.T) {
+ _, ids, code := put("16", `{"labels":[{"id":1}]}`)
+ assert.Equal(t, http.StatusOK, code)
+ assert.ElementsMatch(t, []int64{1}, ids)
+ })
+ t.Run("Read-only share is forbidden", func(t *testing.T) {
+ p, _, code := put("15", `{"labels":[{"id":1}]}`)
+ assert.Equal(t, http.StatusForbidden, code)
+ require.NotNil(t, p)
+ })
+ t.Run("Forbidden task", func(t *testing.T) {
+ // task 34 is private to user13.
+ p, _, code := put("34", `{"labels":[{"id":1}]}`)
+ assert.Equal(t, http.StatusForbidden, code)
+ require.NotNil(t, p)
+ })
+ t.Run("Nonexisting task", func(t *testing.T) {
+ p, _, code := put("9999", `{"labels":[{"id":1}]}`)
+ assert.Equal(t, http.StatusNotFound, code)
+ require.NotNil(t, p)
+ assert.Equal(t, models.ErrCodeTaskDoesNotExist, p.Code)
+ })
+ t.Run("Label the user cannot see is rejected", func(t *testing.T) {
+ // label #3 (user2's, attached to no task user1 can see) is invisible to
+ // user1; attaching it to a writable task must be refused.
+ p, _, code := put("1", `{"labels":[{"id":3}]}`)
+ assert.Equal(t, http.StatusForbidden, code)
+ require.NotNil(t, p)
+ assert.Equal(t, models.ErrCodeUserHasNoAccessToLabel, p.Code)
+ })
+}