diff --git a/pkg/routes/api/v2/sessions.go b/pkg/routes/api/v2/sessions.go
new file mode 100644
index 000000000..aed2d7a66
--- /dev/null
+++ b/pkg/routes/api/v2/sessions.go
@@ -0,0 +1,89 @@
+// 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"
+ "fmt"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+type sessionListBody struct {
+ Body Paginated[*models.Session]
+}
+
+// RegisterSessionRoutes wires the session list/delete operations onto the Huma API.
+// Sessions are created by the login flow, not by CRUD, so there is no create,
+// read-one, or update — hence no max_permission or AutoPatch round trip.
+func RegisterSessionRoutes(api huma.API) {
+ tags := []string{"sessions"}
+
+ Register(api, huma.Operation{
+ OperationID: "sessions-list",
+ Summary: "List sessions",
+ Description: "Returns the authenticated user's own active sessions, most recently active first. Never lists other users' sessions; link share tokens are forbidden.",
+ Method: http.MethodGet,
+ Path: "/user/sessions",
+ Tags: tags,
+ }, sessionsList)
+
+ Register(api, huma.Operation{
+ OperationID: "sessions-delete",
+ Summary: "Delete a session",
+ Description: "Revokes a session by its UUID. Only the owning user may delete it; deleting another user's session is forbidden.",
+ Method: http.MethodDelete,
+ Path: "/user/sessions/{session}",
+ Tags: tags,
+ }, sessionsDelete)
+}
+
+func init() { AddRouteRegistrar(RegisterSessionRoutes) }
+
+func sessionsList(ctx context.Context, in *ListParams) (*sessionListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, _, total, err := handler.DoReadAll(ctx, &models.Session{}, a, in.Q, in.Page, in.PerPage)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ items, ok := result.([]*models.Session)
+ if !ok {
+ return nil, fmt.Errorf("sessions.ReadAll returned unexpected type %T (expected []*models.Session)", result)
+ }
+ return &sessionListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
+}
+
+// The session path param is a string UUID, not an int64 id.
+func sessionsDelete(ctx context.Context, in *struct {
+ Session string `path:"session" doc:"The UUID of the session to delete."`
+}) (*emptyBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := handler.DoDelete(ctx, &models.Session{ID: in.Session}, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
diff --git a/pkg/webtests/huma_session_test.go b/pkg/webtests/huma_session_test.go
new file mode 100644
index 000000000..c61076817
--- /dev/null
+++ b/pkg/webtests/huma_session_test.go
@@ -0,0 +1,95 @@
+// 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"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ sessionUser1A = "550e8400-e29b-41d4-a716-446655440001"
+ sessionUser1B = "550e8400-e29b-41d4-a716-446655440002"
+ sessionUser2 = "550e8400-e29b-41d4-a716-446655440003"
+)
+
+// TestHumaSession mirrors v1's TestSessions session-CRUD matrix (list own vs
+// others', delete own, non-owner forbidden) so v2 parity is readable
+// side-by-side. The login/refresh auth-flow cases in TestSessions are not a
+// session CRUD surface and stay on v1.
+func TestHumaSession(t *testing.T) {
+ testHandler := webHandlerTestV2{
+ user: &testuser1,
+ basePath: "/api/v2/user/sessions",
+ idParam: "session",
+ t: t,
+ }
+
+ t.Run("ReadAll", func(t *testing.T) {
+ t.Run("Normal - exact visible set for user1", func(t *testing.T) {
+ rec, err := testHandler.testReadAllWithUser(nil, nil)
+ require.NoError(t, err)
+
+ ids := sessionIDsFromReadAll(t, rec.Body.Bytes())
+ // User1 owns exactly sessions A and B; user2's session must never appear.
+ assert.ElementsMatch(t, []string{sessionUser1A, sessionUser1B}, ids,
+ "ReadAll must return exactly user1's two sessions; body: %s", rec.Body.String())
+ assert.NotContains(t, ids, sessionUser2, "user2's session must be hidden")
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ t.Run("Normal - own session", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"session": sessionUser1B})
+ require.NoError(t, err)
+ // v2 delete is 204 No Content; v1 returned 200 + a message body.
+ assert.Equal(t, http.StatusNoContent, rec.Code)
+ assert.Empty(t, rec.Body.String())
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"session": "00000000-0000-0000-0000-000000000000"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
+ })
+ t.Run("Forbidden - other user's session", func(t *testing.T) {
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"session": sessionUser2})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ })
+}
+
+// sessionIDsFromReadAll extracts the session UUIDs from a v2 paginated list
+// body so the visible set can be asserted exactly.
+func sessionIDsFromReadAll(t *testing.T, body []byte) []string {
+ t.Helper()
+ var resp struct {
+ Items []struct {
+ ID string `json:"id"`
+ } `json:"items"`
+ }
+ require.NoError(t, json.Unmarshal(body, &resp), "ReadAll body must be a paginated envelope: %s", string(body))
+ ids := make([]string, 0, len(resp.Items))
+ for _, it := range resp.Items {
+ ids = append(ids, it.ID)
+ }
+ return ids
+}