The wax seal of the digital age
You are building a system where Service A sends a configuration payload to Service B. Service B needs to be absolutely certain that Service A sent the data and that no one tampered with it in transit. You cannot rely on network encryption alone. You need a signature.
A digital signature is the cryptographic equivalent of a wax seal on a letter. The seal proves the sender's identity and guarantees the letter was not opened. Anyone can check the seal with the public mold, but only the sender possesses the private stamp. In Go, the crypto standard library packages handle the heavy math so you don't have to implement elliptic curves or modular exponentiation by hand.
How asymmetric signing works
Asymmetric cryptography uses a pair of keys. The private key stays secret. The public key can be shared with anyone. The private key signs data. The public key verifies the signature.
You never sign the raw data directly. RSA and ECDSA operate on fixed-size mathematical blocks. Signing a megabyte of data would be painfully slow. Instead, you hash the data first. A hash function like SHA-256 compresses arbitrary input into a fixed-size fingerprint. You sign the fingerprint. If the data changes by a single byte, the hash changes completely, and the signature verification fails.
The private key stays private. The public key goes public. Never mix them up.
Minimal RSA example
Here is the simplest way to sign and verify data using RSA. This example generates a key in memory, signs a string, and verifies the result.
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"fmt"
)
func main() {
// Generate a 2048-bit RSA key. 2048 is the current security baseline.
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
// Data to sign. In production, this is usually a JSON payload or file hash.
data := []byte("critical-config-update")
// Hash the data. RSA signing requires a fixed-size digest, not raw bytes.
h := sha256.Sum256(data)
// Sign the hash using PKCS#1 v1.5 padding.
// rand.Reader provides entropy for the padding process.
signature, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:])
if err != nil {
panic(err)
}
// Verify the signature using the public key derived from the private key.
err = rsa.VerifyPKCS1v15(&priv.PublicKey, crypto.SHA256, h[:], signature)
if err != nil {
fmt.Println("Verification failed:", err)
return
}
fmt.Println("Signature verified successfully")
}
Walking through the steps
The code follows a strict sequence. First, rsa.GenerateKey creates the key pair. The 2048 argument sets the key size in bits. Smaller keys are faster but vulnerable to brute-force attacks. Larger keys are slower but more secure. 2048 is the standard minimum for new systems.
Next, sha256.Sum256 computes the hash. The crypto.SHA256 constant passed to the signing function tells the RSA library which hash algorithm was used. The library uses this to validate the signature format. If you hash with SHA-256 but tell the verifier it was SHA-512, verification fails.
The signing step uses rsa.SignPKCS1v15. This function implements the PKCS#1 v1.5 padding scheme. Padding adds random bytes to the hash before signing. Without padding, RSA signatures are vulnerable to mathematical attacks that can forge signatures. The rand.Reader argument supplies the randomness for the padding. Never use math/rand here. math/rand is predictable. crypto/rand uses the operating system's entropy source.
Verification mirrors signing. rsa.VerifyPKCS1v15 takes the public key, the hash algorithm, the hash, and the signature. It returns nil if the signature is valid. It returns an error if the signature is invalid, the key is wrong, or the padding is malformed.
RSA is a workhorse. ECDSA is the modern alternative.
Realistic ECDSA example
ECDSA (Elliptic Curve Digital Signature Algorithm) is preferred for new projects. It offers the same security as RSA with much smaller keys and faster operations. A 256-bit ECDSA key is roughly as secure as a 3072-bit RSA key.
Here is how you sign and verify with ECDSA. The flow is similar, but the API differs slightly. ECDSA returns two integers, r and s, which together form the signature.
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"fmt"
)
func main() {
// Generate an ECDSA key on the P-256 curve.
// P-256 is the standard curve for TLS and modern protocols.
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
// Hash the data. ECDSA requires the hash to be passed explicitly.
data := []byte("deploy-prod-v2")
h := sha256.Sum256(data)
// Sign the hash. ECDSA is probabilistic, so the signature changes every time.
r, s, err := ecdsa.Sign(rand.Reader, priv, h[:])
if err != nil {
panic(err)
}
// Verify the signature. The public key must match the private key used.
valid := ecdsa.Verify(&priv.PublicKey, h[:], r, s)
if !valid {
fmt.Println("Verification failed")
return
}
fmt.Println("ECDSA signature verified")
}
Pitfalls and compiler errors
Crypto code looks simple but hides sharp edges. The compiler catches type mismatches, but runtime bugs are harder to spot.
Using math/rand instead of crypto/rand
If you import math/rand and pass rand.Reader to a signing function, the compiler might not complain if the types align. The signature will be generated, but it will be cryptographically broken. An attacker can predict the random padding and forge signatures. Always use crypto/rand.
Signing raw data instead of a hash
The rsa.SignPKCS1v15 function expects a hash, not raw data. If you pass raw bytes, the function treats them as a hash. If the data is longer than the key size, the function panics. If the data is shorter, it pads it incorrectly. The compiler rejects this with cannot use data (variable of type []byte) as crypto.Hash value in argument if you mix up the arguments. Hash the data first.
PKCS#1 v1.5 vs PSS
PKCS#1 v1.5 is the legacy standard. It is widely supported but has theoretical weaknesses. PSS (Probabilistic Signature Scheme) is the modern standard. It is provably secure under standard assumptions. Use rsa.SignPSS and rsa.VerifyPSS for new systems. The API is slightly more verbose because you must specify the padding options.
Key management
Keys are sensitive. Never hardcode keys in source code. Store them in environment variables, secret managers, or encrypted files. When loading keys from PEM format, use x509.ParsePKCS1PrivateKey for RSA or x509.ParseECPrivateKey for ECDSA. If the PEM block is malformed, the parser returns an error. Handle it.
// LoadKey parses a PEM-encoded private key.
// It returns an interface{} because the caller must assert the type.
func LoadKey(pemBytes []byte) (interface{}, error) {
// Decode the PEM block. The Type field is ignored for flexibility.
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
// Parse the key. x509 handles RSA, ECDSA, and Ed25519 automatically.
return x509.ParsePKCS8PrivateKey(block.Bytes)
}
The worst crypto bug is the one that silently accepts a forged signature. Verify every signature. Trust the library, not your intuition.
Decision matrix
Pick the algorithm that fits your constraints. Don't over-engineer the math.
Use RSA when you need maximum compatibility with legacy systems or hardware security modules that only support RSA. Use ECDSA when you want smaller keys, faster operations, and modern security standards. Use HMAC when both parties share a secret key and you don't need asymmetric verification. Use Ed25519 when you want the simplest API, deterministic signatures, and high performance. Use raw hashing when you only need integrity checks and don't care about the sender's identity.