diff --git a/models/fixtures/action_task.yml b/models/fixtures/action_task.yml
index 443effe08c..d88a8ed8a9 100644
--- a/models/fixtures/action_task.yml
+++ b/models/fixtures/action_task.yml
@@ -1,3 +1,22 @@
+-
+  id: 46
+  attempt: 3
+  runner_id: 1
+  status: 3 # 3 is the status code for "cancelled"
+  started: 1683636528
+  stopped: 1683636626
+  repo_id: 4
+  owner_id: 1
+  commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
+  is_fork_pull_request: 0
+  token_hash: 6d8ef48297195edcc8e22c70b3020eaa06c52976db67d39b4260c64a69a2cc1508825121b7b8394e48e00b1bf8718b2aaaaa
+  token_salt: eeeeeeee
+  token_last_eight: eeeeeeee
+  log_filename: artifact-test2/2f/47.log
+  log_in_storage: 1
+  log_length: 707
+  log_size: 90179
+  log_expired: 0
 -
   id: 47
   job_id: 192
diff --git a/services/actions/auth.go b/services/actions/auth.go
index 8e934d89a8..1ef21f6e0e 100644
--- a/services/actions/auth.go
+++ b/services/actions/auth.go
@@ -83,7 +83,12 @@ func ParseAuthorizationToken(req *http.Request) (int64, error) {
 		return 0, fmt.Errorf("split token failed")
 	}
 
-	token, err := jwt.ParseWithClaims(parts[1], &actionsClaims{}, func(t *jwt.Token) (any, error) {
+	return TokenToTaskID(parts[1])
+}
+
+// TokenToTaskID returns the TaskID associated with the provided JWT token
+func TokenToTaskID(token string) (int64, error) {
+	parsedToken, err := jwt.ParseWithClaims(token, &actionsClaims{}, func(t *jwt.Token) (any, error) {
 		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
 			return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
 		}
@@ -93,8 +98,8 @@ func ParseAuthorizationToken(req *http.Request) (int64, error) {
 		return 0, err
 	}
 
-	c, ok := token.Claims.(*actionsClaims)
-	if !token.Valid || !ok {
+	c, ok := parsedToken.Claims.(*actionsClaims)
+	if !parsedToken.Valid || !ok {
 		return 0, fmt.Errorf("invalid token claim")
 	}
 
diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go
index 8b625a193e..b983e57ecd 100644
--- a/services/auth/oauth2.go
+++ b/services/auth/oauth2.go
@@ -18,6 +18,7 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/modules/web/middleware"
+	"code.gitea.io/gitea/services/actions"
 	"code.gitea.io/gitea/services/auth/source/oauth2"
 )
 
@@ -94,6 +95,18 @@ func CheckOAuthAccessToken(ctx context.Context, accessToken string) (int64, stri
 	return grant.UserID, grantScopes
 }
 
+// CheckTaskIsRunning verifies that the TaskID corresponds to a running task
+func CheckTaskIsRunning(ctx context.Context, taskID int64) bool {
+	// Verify the task exists
+	task, err := actions_model.GetTaskByID(ctx, taskID)
+	if err != nil {
+		return false
+	}
+
+	// Verify that it's running
+	return task.Status == actions_model.StatusRunning
+}
+
 // OAuth2 implements the Auth interface and authenticates requests
 // (API requests only) by looking for an OAuth token in query parameters or the
 // "Authorization" header.
@@ -137,8 +150,17 @@ func parseToken(req *http.Request) (string, bool) {
 func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 {
 	// Let's see if token is valid.
 	if strings.Contains(tokenSHA, ".") {
-		uid, grantScopes := CheckOAuthAccessToken(ctx, tokenSHA)
+		// First attempt to decode an actions JWT, returning the actions user
+		if taskID, err := actions.TokenToTaskID(tokenSHA); err == nil {
+			if CheckTaskIsRunning(ctx, taskID) {
+				store.GetData()["IsActionsToken"] = true
+				store.GetData()["ActionsTaskID"] = taskID
+				return user_model.ActionsUserID
+			}
+		}
 
+		// Otherwise, check if this is an OAuth access token
+		uid, grantScopes := CheckOAuthAccessToken(ctx, tokenSHA)
 		if uid != 0 {
 			store.GetData()["IsApiToken"] = true
 			if grantScopes != "" {
diff --git a/services/auth/oauth2_test.go b/services/auth/oauth2_test.go
new file mode 100644
index 0000000000..c9b4ed06cc
--- /dev/null
+++ b/services/auth/oauth2_test.go
@@ -0,0 +1,55 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+	"context"
+	"testing"
+
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/web/middleware"
+	"code.gitea.io/gitea/services/actions"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestUserIDFromToken(t *testing.T) {
+	require.NoError(t, unittest.PrepareTestDatabase())
+
+	t.Run("Actions JWT", func(t *testing.T) {
+		const RunningTaskID = 47
+		token, err := actions.CreateAuthorizationToken(RunningTaskID, 1, 2)
+		require.NoError(t, err)
+
+		ds := make(middleware.ContextData)
+
+		o := OAuth2{}
+		uid := o.userIDFromToken(context.Background(), token, ds)
+		assert.Equal(t, int64(user_model.ActionsUserID), uid)
+		assert.Equal(t, true, ds["IsActionsToken"])
+		assert.Equal(t, ds["ActionsTaskID"], int64(RunningTaskID))
+	})
+}
+
+func TestCheckTaskIsRunning(t *testing.T) {
+	require.NoError(t, unittest.PrepareTestDatabase())
+	cases := map[string]struct {
+		TaskID   int64
+		Expected bool
+	}{
+		"Running":   {TaskID: 47, Expected: true},
+		"Missing":   {TaskID: 1, Expected: false},
+		"Cancelled": {TaskID: 46, Expected: false},
+	}
+
+	for name := range cases {
+		c := cases[name]
+		t.Run(name, func(t *testing.T) {
+			actual := CheckTaskIsRunning(context.Background(), c.TaskID)
+			assert.Equal(t, c.Expected, actual)
+		})
+	}
+}