diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go
index 0d2062ad05..3363c4c0e8 100644
--- a/routers/api/actions/artifacts.go
+++ b/routers/api/actions/artifacts.go
@@ -78,6 +78,7 @@ import (
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
 	web_types "code.gitea.io/gitea/modules/web/types"
+	actions_service "code.gitea.io/gitea/services/actions"
 )
 
 const artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts"
@@ -137,12 +138,33 @@ func ArtifactContexter() func(next http.Handler) http.Handler {
 				return
 			}
 
-			authToken := strings.TrimPrefix(authHeader, "Bearer ")
-			task, err := actions.GetRunningTaskByToken(req.Context(), authToken)
-			if err != nil {
-				log.Error("Error runner api getting task: %v", err)
-				ctx.Error(http.StatusInternalServerError, "Error runner api getting task")
-				return
+			// New act_runner uses jwt to authenticate
+			tID, err := actions_service.ParseAuthorizationToken(req)
+
+			var task *actions.ActionTask
+			if err == nil {
+
+				task, err = actions.GetTaskByID(req.Context(), tID)
+				if err != nil {
+					log.Error("Error runner api getting task by ID: %v", err)
+					ctx.Error(http.StatusInternalServerError, "Error runner api getting task by ID")
+					return
+				}
+				if task.Status != actions.StatusRunning {
+					log.Error("Error runner api getting task: task is not running")
+					ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running")
+					return
+				}
+			} else {
+				// Old act_runner uses GITEA_TOKEN to authenticate
+				authToken := strings.TrimPrefix(authHeader, "Bearer ")
+
+				task, err = actions.GetRunningTaskByToken(req.Context(), authToken)
+				if err != nil {
+					log.Error("Error runner api getting task: %v", err)
+					ctx.Error(http.StatusInternalServerError, "Error runner api getting task")
+					return
+				}
 			}
 
 			if err := task.LoadJob(req.Context()); err != nil {
diff --git a/routers/api/actions/runner/utils.go b/routers/api/actions/runner/utils.go
index 2555f86c80..a7cb31288c 100644
--- a/routers/api/actions/runner/utils.go
+++ b/routers/api/actions/runner/utils.go
@@ -151,6 +151,11 @@ func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct {
 
 	refName := git.RefName(ref)
 
+	giteaRuntimeToken, err := actions.CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID)
+	if err != nil {
+		log.Error("actions.CreateAuthorizationToken failed: %v", err)
+	}
+
 	taskContext, err := structpb.NewStruct(map[string]any{
 		// standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
 		"action":            "",                                                   // string, The name of the action currently running, or the id of a step. GitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2.
@@ -190,6 +195,7 @@ func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct {
 
 		// additional contexts
 		"gitea_default_actions_url": setting.Actions.DefaultActionsURL.URL(),
+		"gitea_runtime_token":       giteaRuntimeToken,
 	})
 	if err != nil {
 		log.Error("structpb.NewStruct failed: %v", err)
diff --git a/services/actions/auth.go b/services/actions/auth.go
new file mode 100644
index 0000000000..53e68f0b71
--- /dev/null
+++ b/services/actions/auth.go
@@ -0,0 +1,77 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
+
+	"github.com/golang-jwt/jwt/v5"
+)
+
+type actionsClaims struct {
+	jwt.RegisteredClaims
+	Scp    string `json:"scp"`
+	TaskID int64
+	RunID  int64
+	JobID  int64
+}
+
+func CreateAuthorizationToken(taskID, runID, jobID int64) (string, error) {
+	now := time.Now()
+
+	claims := actionsClaims{
+		RegisteredClaims: jwt.RegisteredClaims{
+			ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)),
+			NotBefore: jwt.NewNumericDate(now),
+		},
+		Scp:    fmt.Sprintf("Actions.Results:%d:%d", runID, jobID),
+		TaskID: taskID,
+		RunID:  runID,
+		JobID:  jobID,
+	}
+	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+
+	tokenString, err := token.SignedString([]byte(setting.SecretKey))
+	if err != nil {
+		return "", err
+	}
+
+	return tokenString, nil
+}
+
+func ParseAuthorizationToken(req *http.Request) (int64, error) {
+	h := req.Header.Get("Authorization")
+	if h == "" {
+		return 0, nil
+	}
+
+	parts := strings.SplitN(h, " ", 2)
+	if len(parts) != 2 {
+		log.Error("split token failed: %s", h)
+		return 0, fmt.Errorf("split token failed")
+	}
+
+	token, err := jwt.ParseWithClaims(parts[1], &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"])
+		}
+		return []byte(setting.SecretKey), nil
+	})
+	if err != nil {
+		return 0, err
+	}
+
+	c, ok := token.Claims.(*actionsClaims)
+	if !token.Valid || !ok {
+		return 0, fmt.Errorf("invalid token claim")
+	}
+
+	return c.TaskID, nil
+}
diff --git a/services/actions/auth_test.go b/services/actions/auth_test.go
new file mode 100644
index 0000000000..f6288ccd5a
--- /dev/null
+++ b/services/actions/auth_test.go
@@ -0,0 +1,55 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+	"net/http"
+	"testing"
+
+	"code.gitea.io/gitea/modules/setting"
+
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCreateAuthorizationToken(t *testing.T) {
+	var taskID int64 = 23
+	token, err := CreateAuthorizationToken(taskID, 1, 2)
+	assert.Nil(t, err)
+	assert.NotEqual(t, "", token)
+	claims := jwt.MapClaims{}
+	_, err = jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
+		return []byte(setting.SecretKey), nil
+	})
+	assert.Nil(t, err)
+	scp, ok := claims["scp"]
+	assert.True(t, ok, "Has scp claim in jwt token")
+	assert.Contains(t, scp, "Actions.Results:1:2")
+	taskIDClaim, ok := claims["TaskID"]
+	assert.True(t, ok, "Has TaskID claim in jwt token")
+	assert.Equal(t, float64(taskID), taskIDClaim, "Supplied taskid must match stored one")
+}
+
+func TestParseAuthorizationToken(t *testing.T) {
+	var taskID int64 = 23
+	token, err := CreateAuthorizationToken(taskID, 1, 2)
+	assert.Nil(t, err)
+	assert.NotEqual(t, "", token)
+	headers := http.Header{}
+	headers.Set("Authorization", "Bearer "+token)
+	rTaskID, err := ParseAuthorizationToken(&http.Request{
+		Header: headers,
+	})
+	assert.Nil(t, err)
+	assert.Equal(t, taskID, rTaskID)
+}
+
+func TestParseAuthorizationTokenNoAuthHeader(t *testing.T) {
+	headers := http.Header{}
+	rTaskID, err := ParseAuthorizationToken(&http.Request{
+		Header: headers,
+	})
+	assert.Nil(t, err)
+	assert.Equal(t, int64(0), rTaskID)
+}