diff --git a/README.md b/README.md
index 2ba0a7b7..5ea8554f 100644
--- a/README.md
+++ b/README.md
@@ -130,6 +130,28 @@ Changes the passphrase for given role keys file. The CLI supports reading
 both the existing and the new passphrase via the following environment
 variables - `TUF_{{ROLE}}_PASSPHRASE` and respectively `TUF_NEW_{{ROLE}}_PASSPHRASE`
 
+#### `tuf payload <metadata>`
+
+Outputs the metadata file for a role in a ready-to-sign (canonicalized) format.
+
+See also `tuf sign-payload` and `tuf add-signatures`.
+
+#### `tuf sign-payload --role=<role> <path>`
+
+Sign a file (outside of the TUF repo) using keys (in the TUF keys database,
+typically produced by `tuf gen-key`) for the given `role` (from the TUF repo).
+
+Typically, `path` will be a file containing the output of `tuf payload`.
+
+See also `tuf add-signatures`.
+
+#### `tuf add-signatures --signatures <sig_file> <metadata>`
+
+
+Adds signatures (the output of `tuf sign-payload`) to the given role metadata file.
+
+If the signature does not verify, it will not be added.
+
 #### Usage of environment variables
 
 The `tuf` CLI supports receiving passphrases via environment variables in
@@ -229,6 +251,46 @@ Enter root keys passphrase:
 The staged `root.json` can now be copied back to the repo box ready to be
 committed alongside other metadata files.
 
+#### Alternate signing flow
+
+Instead of manually copying `root.json` into the TUF repository on the root box,
+you can use the `tuf payload`, `tuf sign-payload`, `tuf add-signatures` flow.
+
+On the repo box, get the `root.json` payload in a canonical format:
+
+``` bash
+$ tuf payload root.json > root.json.payload
+```
+
+Copy `root.json.payload` to the root box and sign it:
+
+
+``` bash
+$ tuf sign-payload --role=root root.json.payload > root.json.sigs
+Enter root keys passphrase:
+```
+
+Copy `root.json.sigs` back to the repo box and import the signatures:
+
+``` bash
+$ tuf add-signatures --signatures=root.json.sigs root.json
+```
+
+This achieves the same state as the above flow for the repo box:
+
+```bash
+$ tree .
+.
+├── keys
+│   ├── snapshot.json
+│   ├── targets.json
+│   └── timestamp.json
+├── repository
+└── staged
+    ├── root.json
+    └── targets
+```
+
 #### Add a target file
 
 Assuming a staged, signed `root` metadata file and the file to add exists at
diff --git a/client/client_test.go b/client/client_test.go
index 85fa57e1..2085dcd8 100644
--- a/client/client_test.go
+++ b/client/client_test.go
@@ -733,6 +733,36 @@ func (s *ClientSuite) TestNewTargetsKey(c *C) {
 	c.Assert(role.KeyIDs, DeepEquals, sets.StringSliceToSet(newIDs))
 }
 
+func (s *ClientSuite) TestOfflineSignatureFlow(c *C) {
+	client := s.newClient(c)
+
+	// replace key
+	oldIDs := s.keyIDs["targets"]
+	c.Assert(s.repo.RevokeKey("targets", oldIDs[0]), IsNil)
+	_ = s.genKey(c, "targets")
+
+	// re-sign targets using offline flow and generate new snapshot and timestamp
+	payload, err := s.repo.Payload("targets.json")
+	c.Assert(err, IsNil)
+	signed := data.Signed{Signed: payload}
+	_, err = s.repo.SignPayload("targets", &signed)
+	c.Assert(err, IsNil)
+	for _, sig := range signed.Signatures {
+		// This method checks that the signature verifies!
+		err = s.repo.AddOrUpdateSignature("targets.json", sig)
+		c.Assert(err, IsNil)
+	}
+	c.Assert(s.repo.Snapshot(), IsNil)
+	c.Assert(s.repo.Timestamp(), IsNil)
+	c.Assert(s.repo.Commit(), IsNil)
+	s.syncRemote(c)
+
+	// check update gets new metadata
+	c.Assert(client.getLocalMeta(), IsNil)
+	_, err = client.Update()
+	c.Assert(err, IsNil)
+}
+
 func (s *ClientSuite) TestLocalExpired(c *C) {
 	client := s.newClient(c)
 
diff --git a/cmd/tuf/add_signatures.go b/cmd/tuf/add_signatures.go
new file mode 100644
index 00000000..65087360
--- /dev/null
+++ b/cmd/tuf/add_signatures.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/flynn/go-docopt"
+	"github.com/theupdateframework/go-tuf"
+	"github.com/theupdateframework/go-tuf/data"
+)
+
+func init() {
+	register("add-signatures", cmdAddSignature, `
+usage: tuf add-signatures --signatures <sig_file> <metadata>
+
+Adds signatures (the output of "sign-payload") to the given role metadata file.
+
+If the signature does not verify, it will not be added.
+`)
+}
+
+func cmdAddSignature(args *docopt.Args, repo *tuf.Repo) error {
+	roleFilename := args.String["<metadata>"]
+
+	f := args.String["<sig_file>"]
+	sigBytes, err := os.ReadFile(f)
+	if err != nil {
+		return err
+	}
+	sigs := []data.Signature{}
+	if err = json.Unmarshal(sigBytes, &sigs); err != nil {
+		return err
+	}
+	for _, sig := range sigs {
+		if err = repo.AddOrUpdateSignature(roleFilename, sig); err != nil {
+			return err
+		}
+	}
+	fmt.Fprintln(os.Stderr, "tuf: added", len(sigs), "new signature(s)")
+	return nil
+}
diff --git a/cmd/tuf/main.go b/cmd/tuf/main.go
index 4017987e..137420f1 100644
--- a/cmd/tuf/main.go
+++ b/cmd/tuf/main.go
@@ -36,7 +36,10 @@ Commands:
   remove             Remove a target file
   snapshot           Update the snapshot metadata file
   timestamp          Update the timestamp metadata file
+  payload            Output a role's metadata file for signing
+  add-signatures     Adds signatures generated offline
   sign               Sign a role's metadata file
+  sign-payload       Sign a file from the "payload" command.
   commit             Commit staged files to the repository
   regenerate         Recreate the targets metadata file [Not supported yet]
   set-threshold      Sets the threshold for a role
diff --git a/cmd/tuf/payload.go b/cmd/tuf/payload.go
new file mode 100644
index 00000000..8cc0c2ff
--- /dev/null
+++ b/cmd/tuf/payload.go
@@ -0,0 +1,25 @@
+package main
+
+import (
+	"fmt"
+
+	"github.com/flynn/go-docopt"
+	"github.com/theupdateframework/go-tuf"
+)
+
+func init() {
+	register("payload", cmdPayload, `
+usage: tuf payload <metadata>
+
+Outputs the metadata file for a role in a ready-to-sign (canonicalized) format.
+`)
+}
+
+func cmdPayload(args *docopt.Args, repo *tuf.Repo) error {
+	p, err := repo.Payload(args.String["<metadata>"])
+	if err != nil {
+		return err
+	}
+	fmt.Print(string(p))
+	return nil
+}
diff --git a/cmd/tuf/sign_payload.go b/cmd/tuf/sign_payload.go
new file mode 100644
index 00000000..8da5642b
--- /dev/null
+++ b/cmd/tuf/sign_payload.go
@@ -0,0 +1,43 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/flynn/go-docopt"
+	tuf "github.com/theupdateframework/go-tuf"
+	tufdata "github.com/theupdateframework/go-tuf/data"
+)
+
+func init() {
+	register("sign-payload", cmdSignPayload, `
+usage: tuf sign-payload --role=<role> <path>
+
+Sign a file (outside of the TUF repo) using keys for the given role (from the TUF repo).
+
+Typically, path will be the output of "tuf payload".
+`)
+}
+
+func cmdSignPayload(args *docopt.Args, repo *tuf.Repo) error {
+	payload, err := os.ReadFile(args.String["<path>"])
+	if err != nil {
+		return err
+	}
+	signed := tufdata.Signed{Signed: payload, Signatures: make([]tufdata.Signature, 0)}
+
+	numKeys, err := repo.SignPayload(args.String["--role"], &signed)
+	if err != nil {
+		return err
+	}
+
+	bytes, err := json.Marshal(signed.Signatures)
+	if err != nil {
+		return err
+	}
+	fmt.Print(string(bytes))
+
+	fmt.Fprintln(os.Stderr, "tuf: signed with", numKeys, "key(s)")
+	return nil
+}
diff --git a/errors.go b/errors.go
index 09df0390..0051c439 100644
--- a/errors.go
+++ b/errors.go
@@ -17,7 +17,7 @@ type ErrMissingMetadata struct {
 }
 
 func (e ErrMissingMetadata) Error() string {
-	return fmt.Sprintf("tuf: missing metadata %s", e.Name)
+	return fmt.Sprintf("tuf: missing metadata file %s", e.Name)
 }
 
 type ErrFileNotFound struct {
@@ -28,12 +28,12 @@ func (e ErrFileNotFound) Error() string {
 	return fmt.Sprintf("tuf: file not found %s", e.Path)
 }
 
-type ErrInsufficientKeys struct {
+type ErrNoKeys struct {
 	Name string
 }
 
-func (e ErrInsufficientKeys) Error() string {
-	return fmt.Sprintf("tuf: insufficient keys to sign %s", e.Name)
+func (e ErrNoKeys) Error() string {
+	return fmt.Sprintf("tuf: no keys available to sign %s", e.Name)
 }
 
 type ErrInsufficientSignatures struct {
diff --git a/repo.go b/repo.go
index 0553fd61..e4992fe3 100644
--- a/repo.go
+++ b/repo.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"encoding/hex"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"path"
@@ -11,6 +12,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/secure-systems-lab/go-securesystemslib/cjson"
 	"github.com/theupdateframework/go-tuf/data"
 	"github.com/theupdateframework/go-tuf/internal/roles"
 	"github.com/theupdateframework/go-tuf/internal/sets"
@@ -734,33 +736,48 @@ func (r *Repo) setMeta(roleFilename string, meta interface{}) error {
 	return r.local.SetMeta(roleFilename, b)
 }
 
-func (r *Repo) Sign(roleFilename string) error {
-	role := strings.TrimSuffix(roleFilename, ".json")
-
-	s, err := r.SignedMeta(roleFilename)
-	if err != nil {
-		return err
-	}
-
+// SignPayload signs the given payload using the key(s) associated with role.
+//
+// It returns the total number of keys used for signing, 0 (along with
+// ErrNoKeys) if no keys were found, or -1 (along with an error) in error cases.
+func (r *Repo) SignPayload(role string, payload *data.Signed) (int, error) {
 	keys, err := r.signersForRole(role)
 	if err != nil {
-		return err
+		return -1, err
 	}
 	if len(keys) == 0 {
-		return ErrInsufficientKeys{roleFilename}
+		return 0, ErrNoKeys{role}
 	}
 	for _, k := range keys {
-		sign.Sign(s, k)
+		if err = sign.Sign(payload, k); err != nil {
+			return -1, err
+		}
 	}
+	return len(keys), nil
+}
 
-	b, err := r.jsonMarshal(s)
+func (r *Repo) Sign(roleFilename string) error {
+	signed, err := r.SignedMeta(roleFilename)
+	if err != nil {
+		return err
+	}
+
+	role := strings.TrimSuffix(roleFilename, ".json")
+	numKeys, err := r.SignPayload(role, signed)
+	if errors.Is(err, ErrNoKeys{role}) {
+		return ErrNoKeys{roleFilename}
+	} else if err != nil {
+		return err
+	}
+
+	b, err := r.jsonMarshal(signed)
 	if err != nil {
 		return err
 	}
 	r.meta[roleFilename] = b
 	err = r.local.SetMeta(roleFilename, b)
 	if err == nil {
-		fmt.Println("Signed", roleFilename, "with", len(keys), "key(s)")
+		fmt.Println("Signed", roleFilename, "with", numKeys, "key(s)")
 	}
 	return err
 }
@@ -1527,3 +1544,17 @@ func (r *Repo) timestampFileMeta(roleFilename string) (data.TimestampFileMeta, e
 	}
 	return util.GenerateTimestampFileMeta(bytes.NewReader(b), r.hashAlgorithms...)
 }
+
+func (r *Repo) Payload(roleFilename string) ([]byte, error) {
+	s, err := r.SignedMeta(roleFilename)
+	if err != nil {
+		return nil, err
+	}
+
+	p, err := cjson.EncodeCanonical(s.Signed)
+	if err != nil {
+		return nil, err
+	}
+
+	return p, nil
+}
diff --git a/repo_test.go b/repo_test.go
index c71bf5f0..089cf8f6 100644
--- a/repo_test.go
+++ b/repo_test.go
@@ -1,6 +1,7 @@
 package tuf
 
 import (
+	"bytes"
 	"crypto"
 	"crypto/rand"
 	"encoding/hex"
@@ -638,8 +639,8 @@ func (rs *RepoSuite) TestSign(c *C) {
 
 	c.Assert(r.Sign("foo.json"), Equals, ErrMissingMetadata{"foo.json"})
 
-	// signing with no keys returns ErrInsufficientKeys
-	c.Assert(r.Sign("root.json"), Equals, ErrInsufficientKeys{"root.json"})
+	// signing with no keys returns ErrNoKeys
+	c.Assert(r.Sign("root.json"), Equals, ErrNoKeys{"root.json"})
 
 	checkSigIDs := func(keyIDs ...string) {
 		meta, err := local.GetMeta()
@@ -1784,6 +1785,13 @@ func (rs *RepoSuite) TestBadAddOrUpdateSignatures(c *C) {
 	c.Assert(err, IsNil)
 	c.Assert(r.AddVerificationKey("timestamp", timestampKey.PublicData()), IsNil)
 
+	// attempt to sign `root`, rather than `root.json`
+	for _, id := range rootKey.PublicData().IDs() {
+		c.Assert(r.AddOrUpdateSignature("root", data.Signature{
+			KeyID:     id,
+			Signature: nil}), Equals, ErrMissingMetadata{"root"})
+	}
+
 	// add a signature with a bad role
 	rootMeta, err := r.SignedMeta("root.json")
 	c.Assert(err, IsNil)
@@ -2544,3 +2552,46 @@ func (rs *RepoSuite) TestAddOrUpdateSignatureWithDelegations(c *C) {
 	c.Assert(r.Timestamp(), IsNil)
 	c.Assert(r.Commit(), IsNil)
 }
+
+// Test the offline signature flow: Payload -> SignPayload -> AddSignature
+func (rs *RepoSuite) TestOfflineFlow(c *C) {
+	// Set up repo.
+	meta := make(map[string]json.RawMessage)
+	local := MemoryStore(meta, nil)
+	r, err := NewRepo(local)
+	c.Assert(err, IsNil)
+	c.Assert(r.Init(false), IsNil)
+	_, err = r.GenKey("root")
+	c.Assert(err, IsNil)
+
+	// Get the payload to sign
+	_, err = r.Payload("badrole.json")
+	c.Assert(err, Equals, ErrMissingMetadata{"badrole.json"})
+	_, err = r.Payload("root")
+	c.Assert(err, Equals, ErrMissingMetadata{"root"})
+	payload, err := r.Payload("root.json")
+	c.Assert(err, IsNil)
+
+	root, err := r.SignedMeta("root.json")
+	c.Assert(err, IsNil)
+	rootCanonical, err := cjson.EncodeCanonical(root.Signed)
+	c.Assert(err, IsNil)
+	if !bytes.Equal(payload, rootCanonical) {
+		c.Fatalf("Payload(): not canonical.\n%s\n%s", string(payload), string(rootCanonical))
+	}
+
+	// Sign the payload
+	signed := data.Signed{Signed: payload}
+	_, err = r.SignPayload("targets", &signed)
+	c.Assert(err, Equals, ErrNoKeys{"targets"})
+	numKeys, err := r.SignPayload("root", &signed)
+	c.Assert(err, IsNil)
+	c.Assert(numKeys, Equals, 1)
+
+	// Add the payload signatures back
+	for _, sig := range signed.Signatures {
+		// This method checks that the signature verifies!
+		err = r.AddOrUpdateSignature("root.json", sig)
+		c.Assert(err, IsNil)
+	}
+}