The local server that won't trust itself
You're building a microservice and want to test HTTPS behavior without configuring a full certificate authority. You start the server, hit the endpoint, and the browser screams that the connection is not private. Or a CI pipeline fails because a mock service expects a valid TLS handshake. You need a certificate that works right now, on this machine, for testing or internal tools. You don't need a CA-signed cert from Let's Encrypt. You need a self-signed certificate generated by your code.
A self-signed certificate solves the immediate need. It trades universal trust for instant availability.
What a self-signed certificate actually is
A certificate is a digital ID card. It binds a public key to a name, like a domain or an organization. A normal certificate gets signed by a Certificate Authority (CA) that browsers trust. A self-signed certificate is signed by its own private key. It's like writing your own driver's license. The information is there, the signature is mathematically valid, but nobody else has agreed to trust you. Browsers reject it by default because anyone can create one. For local development, internal services, or testing, that's fine. You control the environment, so you can choose to trust it.
Go's standard library provides everything you need to create these certificates in memory. The crypto/x509 package handles the certificate structure and signing. The crypto/ecdsa package generates the keys. The encoding/pem package formats the output for storage or transmission.
Minimal example: key, template, sign
Here's the simplest way to generate a certificate and key pair in memory. The code creates an ECDSA key, fills a certificate template, signs the template with the key, and encodes the result to PEM format.
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"time"
)
func main() {
// Generate an ECDSA private key using the P-256 curve.
// P-256 is the standard for modern TLS; it's fast and secure.
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
// Set validity period. Self-signed certs usually last a year for testing.
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour)
// Build the certificate template.
// This struct defines who the cert is for and what it can do.
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Local Dev"},
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Sign the certificate with its own key.
// The template signs itself, creating a self-signed cert.
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
panic(err)
}
// Marshal the private key to DER format for PEM encoding.
privBytes, err := x509.MarshalECPrivateKey(priv)
if err != nil {
panic(err)
}
// Encode both to PEM so they can be written to files or stdout.
pem.Encode(os.Stdout, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes})
pem.Encode(os.Stdout, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
}
Generate the key, fill the template, sign the template. PEM is just a text wrapper around binary data.
Understanding the certificate template
The x509.Certificate struct is the blueprint for your certificate. Every field matters. Leaving fields empty can produce a certificate that looks valid but fails validation in strict clients.
The SerialNumber must be unique for every certificate issued by the same authority. Reusing serial numbers can break validation in some clients. Use big.NewInt(time.Now().UnixNano()) or a cryptographic random number to ensure uniqueness. The compiler won't stop you from reusing a serial number, but the client might reject the cert.
The Subject holds the identity information. pkix.Name contains fields like Organization and CommonName. These are legacy fields. Modern validation relies on Subject Alternative Names, not the subject name. Set Organization to something recognizable for debugging, but don't rely on it for validation.
NotBefore and NotAfter define the validity window. If NotBefore is in the future, the certificate is not yet valid. If NotAfter is in the past, it's expired. The compiler won't check these values. The client will reject the cert during the handshake. Set NotBefore to time.Now() and NotAfter to a future time.
KeyUsage controls what the key can do. KeyUsageDigitalSignature allows signing data. KeyUsageKeyEncipherment allows encrypting keys for key exchange. TLS needs both for RSA, but ECDSA uses signatures. Setting both is safe practice. Omit KeyUsage and you get a certificate that technically exists but fails TLS handshakes. The error appears as tls: internal error on the server or a handshake failure on the client.
ExtKeyUsage specifies the purpose. ExtKeyUsageServerAuth marks the cert for TLS servers. ExtKeyUsageClientAuth marks it for client certificates. Include ServerAuth for HTTPS servers.
BasicConstraintsValid signals that the IsCA field is meaningful. Set it to true even for leaf certificates to avoid warnings. If you set IsCA to true, the certificate becomes a CA certificate. That's usually not what you want for a server cert.
Fill every field intentionally. An empty template produces a cert that looks valid but fails validation.
Subject Alternative Names are mandatory
Browsers ignore CommonName for validation. They require Subject Alternative Names (SANs). Add DNSNames or IPAddresses to the template. Without these, Chrome and Firefox show a security warning. The Go tls package might accept the cert, but your users won't.
Update the template to include SANs:
template := x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{
CommonName: "localhost",
Organization: []string{"Local Dev"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
// Add SANs for modern browser compatibility.
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
CommonName is for humans. SANs are for machines. Put the hostname in the SANs.
PEM vs DER: encoding matters
DER is the binary format of the certificate. PEM is a Base64 encoding of DER wrapped in headers. The headers tell parsers what the data is. -----BEGIN CERTIFICATE----- and -----BEGIN EC PRIVATE KEY-----.
Go's pem package handles the encoding. pem.EncodeToMemory returns a byte slice. pem.Encode writes to an io.Writer. Use EncodeToMemory when you need the bytes for a tls.Config. Use Encode when writing to a file or stdout.
The type string in the PEM block must match the key type. For ECDSA keys, use EC PRIVATE KEY. For RSA keys, use RSA PRIVATE KEY. Using the wrong type causes the parser to fail. The compiler won't catch this. The runtime will panic or return an error.
DER is the data. PEM is the transport. Encode to PEM before passing to TLS or writing to disk.
Realistic example: HTTPS server with dynamic cert
Here's how to use the generated certificate in an HTTP server. The code creates a cert pair, loads it into a tls.Config, and starts the server.
// GenerateCertPair returns PEM-encoded certificate and key bytes.
func GenerateCertPair(cn string) ([]byte, []byte, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
template := x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{
CommonName: cn,
Organization: []string{"Local Test"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{cn},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, nil, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: x509.MarshalECPrivateKey(priv)})
return certPEM, keyPEM, nil
}
// RunHTTPS starts a server with the generated certificate.
func RunHTTPS(addr string, handler http.Handler) error {
certPEM, keyPEM, err := GenerateCertPair("localhost")
if err != nil {
return err
}
// Parse the PEM bytes into a tls.Certificate.
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return err
}
server := &http.Server{
Addr: addr,
Handler: handler,
TLSConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
},
}
// ListenAndServeTLS takes file paths, but empty strings work with TLSConfig.
return server.ListenAndServeTLS("", "")
}
The server presents the cert. The client validates the chain. For local dev, you are the chain.
Pitfalls and errors
Serial numbers must be unique. If you generate multiple certificates in a loop without updating the serial number, clients may reject duplicates. Use a timestamp or random value.
SANs must match the host. If the client connects to localhost but the cert only has 127.0.0.1 in the SANs, the handshake fails. The browser shows a common-name-invalid error. Add both DNS names and IPs if needed.
Expiry must be future. If NotAfter is in the past, the cert is expired. The error is x509: certificate has expired or is not yet valid. Check your time calculations.
Key usage must be correct. If you omit KeyUsage or ExtKeyUsage, the cert might be rejected. The error is often tls: internal error or a handshake failure. Set KeyUsageDigitalSignature and KeyUsageKeyEncipherment. Set ExtKeyUsageServerAuth.
Error handling is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Return errors from functions. Don't ignore them with _ unless you really mean it. In the minimal example, panic is acceptable for a script. In production code, return errors and let the caller decide.
Serial numbers must be unique. SANs must match the host. Expiry must be future. A broken cert stops traffic before your handler runs.
When to generate certificates in code
Use in-memory generation when your application creates certificates on the fly, such as a mock server in integration tests or a service mesh sidecar.
Use mkcert when you are a developer who wants a local certificate that browsers trust immediately without configuration.
Use openssl scripts when you need to generate static certificates for a CI/CD pipeline or a long-lived internal service.
Use a managed certificate service when your application runs behind a load balancer or on a platform that handles TLS termination.
Code generates certs. Tools manage trust. Pick the right layer for your problem.