How to Sign and Verify Data with RSA or ECDSA in Go

Sign and verify data in Go using the crypto/rsa and crypto/ecdsa packages with SHA256 hashing.

How to Sign and Verify Data with RSA or ECDSA in Go

You're building a tool that downloads configuration from a remote server. The config contains sensitive paths or API keys. You fetch the JSON, but a malicious actor could intercept the request and swap the keys for their own. You need a way to prove the data came from the server and wasn't modified in transit. That's where digital signatures come in. You sign the data with a private key, and anyone with the public key can verify the signature matches the data.

Think of a digital signature like a wax seal on an envelope. You have a unique stamp (the private key) that you keep in a locked drawer. You press the stamp into hot wax on the envelope. Anyone can see the wax seal (the signature) and compare it to a photo of your stamp pattern (the public key). If the wax matches the pattern, the envelope hasn't been opened and resealed by someone else. In crypto terms, you hash the data to get a fixed-size fingerprint, then encrypt that fingerprint with your private key. The result is the signature. Verification reverses the process: decrypt the signature with the public key to get the fingerprint, hash the data again, and check if the fingerprints match.

Minimal RSA Example

Here's the simplest way to sign and verify data using RSA. The code generates a key, signs a hash, and verifies the result.

package main

import (
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"fmt"
)

func main() {
	// Generate a 2048-bit RSA private key.
	// 2048 bits is the current minimum standard for security.
	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		panic(err)
	}

	data := []byte("critical-config-value")

	// Hash the data before signing.
	// RSA signs the hash, not the raw data, for efficiency and security.
	hash := sha256.Sum256(data)

	// Sign the hash using PKCS#1 v1.5 padding.
	// This is the standard RSA signature scheme.
	signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hash[:])
	if err != nil {
		panic(err)
	}

	// Verify the signature using the public key.
	// The public key is embedded in the private key struct for convenience here.
	err = rsa.VerifyPKCS1v15(&privateKey.PublicKey, crypto.SHA256, hash[:], signature)
	if err != nil {
		panic(err)
	}

	fmt.Println("Signature verified successfully")
}

Sign the hash, not the data.

What happens under the hood

The code performs three distinct steps. First, it hashes the data with SHA-256. RSA operations are expensive and limited by the key size. A 2048-bit key can only sign about 245 bytes of data directly. Hashing compresses any amount of data into a 32-byte digest. Second, it signs the digest. The SignPKCS1v15 function takes the private key, the hash algorithm identifier, and the digest. It applies padding and performs the modular exponentiation to produce the signature. Third, it verifies. The verifier uses the public key to reverse the math and recover the digest, then compares it to a fresh hash of the data. If they match, the data is authentic.

The crypto.SHA256 argument tells the function which hash algorithm was used. The verifier checks this to ensure it computes the comparison hash correctly. Passing the wrong hash constant causes verification to fail silently or panic depending on the implementation.

Realistic workflow with PEM keys

Real applications don't keep keys in memory structs. They load keys from files or environment variables. PEM is the standard text format for storing keys. Go's crypto/x509 package handles PEM decoding and key parsing.

Here's how to generate a key, encode it to PEM, and load it back.

package main

import (
	"crypto/rand"
	"crypto/rsa"
	"crypto/x509"
	"encoding/pem"
	"fmt"
)

func main() {
	// Generate a key for demonstration.
	// In production, you would load this from a secure file or vault.
	privateKey, _ := rsa.GenerateKey(rand.Reader, 2048)

	// Marshal the private key to PKCS#8 format.
	// PKCS#8 is the standard container format for private keys.
	keyBytes, _ := x509.MarshalPKCS8PrivateKey(privateKey)

	// Encode to PEM for text storage.
	// PEM adds headers and base64 encoding for safe transport.
	pemBlock := &pem.Block{
		Type:  "PRIVATE KEY",
		Bytes: keyBytes,
	}
	pemBytes := pem.EncodeToMemory(pemBlock)

	fmt.Printf("PEM Key:\n%s", pemBytes)
}

Now load the PEM and sign data. The parser returns an interface, so you must type assert.

package main

import (
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/pem"
	"fmt"
)

func main() {
	// Simulate loading the key back from storage.
	block, _ := pem.Decode(pemBytes)
	loadedKey, _ := x509.ParsePKCS8PrivateKey(block.Bytes)

	// Type assert to *rsa.PrivateKey.
	// x509.ParsePKCS8PrivateKey returns any because it supports multiple algorithms.
	rsaKey, ok := loadedKey.(*rsa.PrivateKey)
	if !ok {
		panic("not an RSA key")
	}

	data := []byte("production-secret")
	hash := sha256.Sum256(data)

	signature, _ := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, hash[:])

	// Verify using the public key derived from the loaded private key.
	err := rsa.VerifyPKCS1v15(&rsaKey.PublicKey, crypto.SHA256, hash[:], signature)
	if err != nil {
		panic(err)
	}

	fmt.Println("PEM round-trip successful")
}

Type assert after parsing, or the compiler stops you.

ECDSA and the Signer interface

ECDSA works differently under the hood. The ecdsa.Sign function returns two big integers, r and s, instead of a byte slice. You have to pack these into a DER-encoded structure to store or transmit the signature. Verification requires unpacking. This is annoying.

package main

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/sha256"
	"fmt"
)

func main() {
	// Generate an ECDSA key on the P-256 curve.
	key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)

	data := []byte("ecdsa-test")
	hash := sha256.Sum256(data)

	// ECDSA signing returns two integers.
	r, s, err := ecdsa.Sign(rand.Reader, key, hash[:])
	if err != nil {
		panic(err)
	}

	// Verification takes the integers directly.
	valid := ecdsa.Verify(&key.PublicKey, hash[:], r, s)
	fmt.Println("Valid:", valid)
}

ECDSA signatures change every time. That's a feature.

The crypto.Signer interface solves the fragmentation. Signer.Sign takes a hash and returns a []byte signature. RSA and ECDSA keys implement this interface. If you use Signer, you don't care about r and s. You get a portable byte slice. This is the recommended pattern for libraries.

package main

import (
	"crypto"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/sha256"
	"fmt"
)

func main() {
	key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)

	// Use the Signer interface to get a byte slice signature.
	// This abstracts away the algorithm-specific details.
	var signer crypto.Signer = key
	hash := sha256.Sum256([]byte("unified-signing"))

	sig, err := signer.Sign(rand.Reader, hash[:], crypto.SignerOpts(crypto.SHA256))
	if err != nil {
		panic(err)
	}

	fmt.Printf("Signature length: %d bytes\n", len(sig))
}

Accept crypto.Signer, return concrete keys.

Pitfalls and compiler errors

The compiler rejects rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, data) with rsa.SignPKCS1v15: message too long for RSA key size if you pass raw data instead of a hash. The function expects a digest. If you load a key with x509.ParsePKCS8PrivateKey, the result is an any type. Trying to call rsa.SignPKCS1v15(rand.Reader, loadedKey, ...) fails with cannot use loadedKey (variable of type any) as *rsa.PrivateKey value in argument. You must type assert: rsaKey := loadedKey.(*rsa.PrivateKey).

ECDSA signatures are probabilistic. Calling ecdsa.Sign twice on the same data produces different signatures. This is normal and secure. Don't cache signatures expecting them to match. Verification will still succeed because the math validates the relationship between the signature and the data.

PEM headers matter. PRIVATE KEY usually implies PKCS#8. RSA PRIVATE KEY implies PKCS#1. Use MarshalPKCS8PrivateKey and ParsePKCS8PrivateKey for the modern standard. Mixing formats causes parsing errors. Go's x509 package supports both, but PKCS#8 is the convention for new code.

When to use which algorithm

Use RSA with PKCS#1 v1.5 when you need maximum compatibility with legacy systems that only support older signature standards. Use RSA with PSS padding when you want stronger security guarantees against padding oracle attacks and your peers support modern standards. Use ECDSA with P-256 when you need smaller signatures and faster verification, such as in mobile apps or constrained environments. Use Ed25519 when you want the best performance and simplicity without managing key sizes or padding schemes. Use HMAC when you share a symmetric secret with the verifier and don't need non-repudiation.

Where to go next