neondatabase/neon · critical

no private key found in {}

Error message

no private key found in {}

What it means

load_private_key read the key file and rustls_pemfile::private_key parsed it without an I/O or PEM-decoding error, but found no PEM section it recognizes as a private key. rustls-pemfile only accepts 'RSA PRIVATE KEY' (PKCS#1), 'PRIVATE KEY' (PKCS#8), 'EC PRIVATE KEY' (SEC1), and encrypted PKCS#8 sections; a file containing only certificates, an OpenSSH-format key, or a DER/binary blob yields None, which becomes this error naming the file path.

Source

Thrown at libs/http-utils/src/tls_certs.rs:37

    let mut reader = std::io::Cursor::new(&cert_data);

    let cert_chain = rustls_pemfile::certs(&mut reader)
        .collect::<Result<Vec<_>, _>>()
        .context(format!("failed parsing certificate from file {filename:?}"))?;

    Ok(cert_chain)
}

pub async fn load_private_key(filename: &Utf8Path) -> anyhow::Result<PrivateKeyDer<'static>> {
    let key_data = tokio::fs::read(filename)
        .await
        .context(format!("failed reading private key file {filename:?}"))?;
    let mut reader = std::io::Cursor::new(&key_data);

    let key = rustls_pemfile::private_key(&mut reader)
        .context(format!("failed parsing private key from file {filename:?}"))?;

    key.ok_or(anyhow::anyhow!(
        "no private key found in {}",
        filename.as_str(),
    ))
}

pub async fn load_certified_key(
    key_filename: &Utf8Path,
    cert_filename: &Utf8Path,
) -> anyhow::Result<CertifiedKey> {
    let cert_chain = load_cert_chain(cert_filename).await?;
    let key = load_private_key(key_filename).await?;

    let key = rustls::crypto::ring::default_provider()
        .key_provider
        .load_private_key(key)?;

    let certified_key = CertifiedKey::new(cert_chain, key);
    certified_key.keys_match()?;

View on GitHub (pinned to 8f60b04da4)

Solutions

  1. Verify the file actually contains a private key section: grep 'PRIVATE KEY' /path/to/key (expect '-----BEGIN ... PRIVATE KEY-----')
  2. If key/cert paths were swapped, point the key setting at the real key file and the cert setting at the chain
  3. Regenerate or convert the key into a supported format: 'openssl genpkey -algorithm RSA' / '-algorithm EC' (PKCS#8), or 'openssl pkcs8 -topk8 -nocrypt -in old.key -out key.pem'
  4. Check the secret/volume actually delivered bytes: 'wc -c' and 'head -1' on the file
  5. For encrypted keys ('ENCRYPTED PRIVATE KEY'), decrypt first with 'openssl pkey -in key.enc -out key.pem'

Example fix

# before: key was made with ssh-keygen (OpenSSH format, unsupported)
#   -----BEGIN OPENSSH PRIVATE KEY-----
ssh-keygen -t ed25519 -f tls.key

# after: generate a PKCS#8 key rustls-pemfile can load
openssl genpkey -algorithm RSA -out tls.key
# or convert an existing PEM key to unencrypted PKCS#8
openssl pkcs8 -topk8 -nocrypt -in tls_old.key -out tls.key
Defensive patterns

Strategy: validation

Validate before calling

use tokio::io::AsyncReadExt;

/// Fails with an actionable message before server start if the file has no
/// PEM section rustls-pemfile recognizes as a private key.
pub async fn ensure_private_key_pem(path: &camino::Utf8Path) -> anyhow::Result<()> {
    let mut data = String::new();
    tokio::fs::File::open(path)
        .await?
        .read_to_string(&mut data)
        .await?;
    let has_key = data
        .lines()
        .any(|l| l.starts_with("-----BEGIN") && l.contains("PRIVATE KEY-----"));
    anyhow::ensure!(
        has_key,
        "{path} has no `-----BEGIN ... PRIVATE KEY-----` section \
         (supported: PKCS#1 RSA, PKCS#8, SEC1 EC; OpenSSH keys are NOT supported)"
    );
    Ok(())
}

Try / catch

match tls_certs::load_certified_key(&key_path, &cert_path).await {
    Ok(k) => k,
    Err(e) if format!("{e:#}").contains("no private key found") => {
        // Refuse to start with a precise hint instead of crashing later on TLS accept
        anyhow::bail!(
            "TLS key {key_path} unusable: {e:#}. \
             Check key/cert paths are not swapped and the key is PKCS#8/PKCS#1/SEC1 PEM, \
             not OpenSSH or DER."
        )
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Pointing the TLS key setting at the certificate file (paths swapped) so the file has only 'CERTIFICATE' sections; using an ssh-keygen ed25519 key ('-----BEGIN OPENSSH PRIVATE KEY-----') which rustls-pemfile cannot parse; an empty or truncated file after a failed secret mount; a raw DER key not wrapped in base64 PEM armor.

Common situations: Kubernetes/Docker secrets mounted empty or as a directory at startup; cert-manager or vault output where the key and cert variable names were swapped; ops staff generating TLS keys with ssh-keygen instead of openssl; encrypted keys whose PEM section the reader skips without a password provider.

Related errors


AI-assisted analysis of neondatabase/neon@8f60b04da4 (2026-08-16). Data as JSON: /api/errors/aeb4a27d9a198914. Report an issue: GitHub.