When you need HTTPS without a CA
You are spinning up a local API and need HTTPS to test browser cookies or CORS policies. Buying a domain feels like overkill. Setting up a full certificate authority infrastructure is a rabbit hole you do not want to go down right now. You need a certificate that works immediately, generated by your code, without external dependencies.
Go's standard library handles this entirely in memory. You can generate a private key, sign a certificate, and start a TLS server in a single process. The crypto/x509 package provides the tools to build certificates from scratch, and crypto/ecdsa gives you modern key generation. No external binaries, no network calls, no configuration files.
What a self-signed certificate actually is
A certificate binds a public key to an identity. A self-signed certificate is one where the issuer and the subject are the same entity. You sign your own ID card. The math proves the key belongs to the certificate, but no third party vouches for the identity.
Browsers will warn users because the certificate is not in their trust store. For development, internal tools, or ephemeral services, that warning is acceptable. You control the environment, so you can trust the certificate yourself. The goal is to get the TLS handshake working so you can focus on application logic, not infrastructure.
Self-signed certificates prove possession of a key, not identity. Trust is added by the client, not the certificate.
Generating a key and template
Here is the core generation logic. We create an ECDSA key, define the certificate metadata, and prepare the template. ECDSA is the modern standard for certificates. It offers strong security with smaller key sizes than RSA, which means faster handshakes and less data on the wire.
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"time"
)
func main() {
// Generate a P-256 ECDSA private key.
// ECDSA keys are smaller and faster than RSA for equivalent security.
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
// Set the certificate validity window.
// Short lifespans reduce the risk window if the key is compromised.
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour)
// Build the certificate template.
// CommonName must match the hostname for basic TLS verification.
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "localhost",
Organization: []string{"Dev"},
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
}
The x509.Certificate struct holds all the metadata. SerialNumber must be a positive integer. The X.509 specification rejects negative serials, and big.NewInt(1) satisfies the requirement. For production-like setups, use rand.Int to generate unique serials.
CommonName identifies the host. Modern clients prefer DNSNames or IPAddresses over CommonName, but CommonName still works for basic validation. Including DNSNames: []string{"localhost"} improves compatibility with strict browsers.
KeyUsage and ExtKeyUsage restrict what the key can do. Setting KeyUsageDigitalSignature allows the key to sign data. KeyUsageKeyEncipherment allows key exchange. ExtKeyUsageServerAuth marks the certificate as valid for TLS server authentication. If you omit these flags, strict clients may reject the certificate during the handshake.
BasicConstraintsValid tells the parser that the certificate is not a CA certificate. This prevents the cert from being used to sign other certificates, which is the correct setting for a leaf certificate.
Go's error handling is explicit. You see if err != nil everywhere. In crypto code, this verbosity is a feature. Hiding errors can lead to silent failures where a server starts with an invalid certificate. The community accepts the boilerplate because it forces you to acknowledge the failure path.
Signing and writing the certificate
Now we sign the template and write the results to disk. The certificate becomes DER bytes, which we wrap in PEM format for compatibility. DER is a binary format defined by ASN.1. Most tools expect PEM, which is base64-encoded DER with text headers.
import (
"encoding/pem"
"os"
)
// ... inside main ...
// Create the certificate.
// Passing template as both subject and issuer makes it self-signed.
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
panic(err)
}
// Write certificate to PEM file.
// PEM is the base64-encoded format expected by most tools and browsers.
certOut, err := os.Create("cert.pem")
if err != nil {
panic(err)
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
panic(err)
}
// Marshal and write the private key.
// EC keys require specific marshaling before PEM encoding.
privBytes, err := x509.MarshalECPrivateKey(priv)
if err != nil {
panic(err)
}
keyOut, err := os.Create("key.pem")
if err != nil {
panic(err)
}
defer keyOut.Close()
if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes}); err != nil {
panic(err)
}
}
x509.CreateCertificate signs the template with the private key. Since we pass the template as both the subject and the issuer, the result is self-signed. The function returns DER-encoded bytes.
pem.Encode adds the -----BEGIN CERTIFICATE----- wrapper and base64-encodes the data. The block type must be CERTIFICATE. Using the wrong type string causes parsing errors in downstream tools.
The private key needs x509.MarshalECPrivateKey to convert the Go key object to DER. EC keys use a different marshaling function than RSA keys. After marshaling, we encode the key with pem.Encode using the type EC PRIVATE KEY.
The underscore _ discards a value intentionally. In larger programs, you might see result, _ := someFunc() to drop a return value. Using _ signals that you considered the return value and chose to drop it. Use it sparingly with errors. In this example, we handle errors explicitly because crypto failures are critical.
How the pieces fit together
The flow is linear. Key generation produces the math. The template defines the policy. Signing binds them together. Encoding makes the result usable.
ecdsa.GenerateKey creates a private key and extracts the public key. The public key goes into the certificate. The private key stays secret and is used to sign.
x509.CreateCertificate takes the template, the issuer template, the public key, and the private key. It validates the template, computes the signature, and returns the DER bytes. If the serial number is negative, the function returns an error. The compiler will not catch this; it is a runtime validation.
If you pass a negative serial number, the function returns x509: certificate serial number must be positive. If the key types do not match, you get x509: failed to sign certificate: crypto/ecdsa: public key is not an ECDSA public key. These errors happen at runtime, so test your generation code early.
The result is a pair of files. cert.pem contains the public certificate. key.pem contains the private key. You can load these into any TLS server. Browsers will warn about the self-signed status, but the connection will be encrypted.
A certificate without the right usage flags is just a signed blob. Set the flags or get rejected.
In-memory certificates for HTTP servers
Writing files is useful for debugging, but many tools generate certificates in memory. Here is how to create a cert and load it directly into an HTTP server without touching the disk. This pattern is common in testing frameworks and ephemeral services.
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net/http"
"time"
)
func main() {
// Generate key and cert in memory.
// ECDSA P-256 is fast and secure for local development.
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x5509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
derBytes, _ := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
}
The generation logic is identical. We use _ to discard errors because key generation failure is extremely rare and usually indicates a broken random source. In a library, you would return the error. In a small script, panicking is acceptable.
// ... inside main ...
// Convert to PEM blocks for tls.LoadX509KeyPair.
// The TLS package expects PEM-encoded bytes, not DER.
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyPEM, _ := x509.MarshalECPrivateKey(priv)
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyPEM})
// Load into a TLS config.
// This avoids writing files to disk for ephemeral servers.
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
panic(err)
}
server := &http.Server{
Addr: ":8443",
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Secure!"))
})
server.ListenAndServeTLS("", "")
}
pem.EncodeToMemory returns PEM-encoded bytes directly. We pass these to tls.X509KeyPair, which parses the PEM blocks and returns a tls.Certificate struct. The TLS package expects PEM, so we must encode the DER bytes before loading.
The http.Server uses the certificate in its TLSConfig. ListenAndServeTLS starts the server. The empty strings for cert and key files tell the server to use the TLSConfig instead of loading files.
If you load a certificate with a key that does not match the public key, the TLS handshake fails. The runtime panics with tls: private key does not match public key. Ensure you use the same private key for signing and serving.
Common pitfalls and runtime errors
Serial numbers must be positive. If you use big.NewInt(0) or a negative number, x509.CreateCertificate returns an error. Use rand.Int to generate unique serials for production-like setups.
CommonName deprecation is real. Browsers are moving away from CommonName. Use DNSNames: []string{"localhost"} in the template for better compatibility. Some clients will reject certificates that only have a CommonName.
Key mismatch panics are common. If you load a cert with a key that does not match the public key, the TLS handshake fails. The runtime panics with tls: private key does not match public key. Ensure you use the same private key for signing and serving.
PEM type strings matter. The block type must be CERTIFICATE for certs and EC PRIVATE KEY for EC keys. Using RSA PRIVATE KEY for an EC key causes parsing errors. The parser checks the type string and the key format.
If you pass a negative serial number, the function returns x509: certificate serial number must be positive. If the key types do not match, you get x509: failed to sign certificate: crypto/ecdsa: public key is not an ECDSA public key. These errors happen at runtime, so test your generation code early.
Browsers hate self-signed certs. Your code does not have to. Configure your test clients to trust the cert or skip verification.
Choosing the right approach
Use crypto/x509 generation when you need dynamic certificates for development servers or internal service meshes. Use openssl command-line tools when you need a quick certificate for a one-off script or non-Go environment. Use golang.org/x/crypto/acme/autocert when you need publicly trusted certificates for production domains. Use static certificate files when the certificate is fixed and known at build time.
Trust is a feature. Choose the right level for your threat model.