Previous parts:

Today, we will create an HTTP/3 client to interact with our HTTP/3 Server.

First of all, you’ll need a running HTTP/3 server. Run it in a separate terminal:

go run server.go

Client skipping TLS verification

Let’s write a simple HTTP/3 client for our server. It’s pretty straightforward: we need to create a new HTTP/3 transport and pass it to an HTTP client from net/http. Because HTTP/3 is built on top of QUIC, it requires us to use TLS. Since we are using a self-generated certificate in the server, the client can’t verify the certificate. One of the options is to skip TLS verification on the client:

tr := &http3.Transport{
    TLSClientConfig: &tls.Config{
        InsecureSkipVerify: true, // skip TLS verification
    },
}
client := &http.Client{
    Transport: tr,
}

The rest is the same as with a regular net/http client. Let’s test it by calling our server:

resp, err := client.Get("https://localhost:8080")
if err != nil {
    panic(err)
}
defer resp.Body.Close()

fmt.Printf("Response status: %s\n", resp.Status)
body, err := io.ReadAll(resp.Body) // read the response body
if err != nil {
    panic(err)
}

fmt.Printf("Response body: %s\n", body) // print the response body
Full code
// client.go
package main

import (
    "crypto/tls"
    "fmt"
    "io"
    "net/http"

    "github.com/quic-go/quic-go/http3"
)

func main() {
    tr := &http3.Transport{
        TLSClientConfig: &tls.Config{
            InsecureSkipVerify: true, // skip TLS verification
        },
    }
    client := &http.Client{
        Transport: tr,
    }
    resp, err := client.Get("https://localhost:8080")
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    fmt.Printf("Response status: %s\n", resp.Status)
    body, err := io.ReadAll(resp.Body) // read the response body
    if err != nil {
        panic(err)
    }

    fmt.Printf("Response body: %s\n", body) // print the response body
}

Running it all together should yield the following:

$ go run client.go
Response status: 200 OK
Response body: Hello, World!

Trusting Server’s TLS Certificate

Another option to make our client trust the server’s certificate is to load it into the client’s certificate pool and make the client use this pool (instead of the system’s one) to verify the connection.

First step is to load server’s certificate generated in the previous step.

// load server's cert in PEM format
certPem, err := os.ReadFile("cert.pem")
if err != nil {
    panic(err)
}

As it is PEM-encoded data, we need to decode it and extract only the CERTIFICATE block.

// there is only one block CERTIFICATE in the cert file
certRaw, _ := pem.Decode(certPem) // decode the PEM encoded cert
cert, err := x509.ParseCertificate(certRaw.Bytes)
if err != nil {
    panic(err)
}

Now, create a new certificate pool with the loaded certificate for our client to use:

// create a cert pool and add the server's cert
certPool := x509.NewCertPool()
certPool.AddCert(cert)

Or, to make this whole process easier, we can use:

certPool.AppendCertsFromPEM(certData)

Which will do the similar thing for us - parse the certificates from the file and add them to the pool.

Now, pass the certificate pool to the client’s TLS config. Note that there is no InsecureSkipVerify option, so our client will use certificates from the pool to validate the server’s certificate.

tr := &http3.Transport{
    TLSClientConfig: &tls.Config{
        RootCAs: certPool, // use the cert pool with server's cert
    },
}

The rest is the same, pass the transport to the client and try to call the server. And if you run it again, you should receive the same output as before.

Full code
// client.go
package main

import (
    "crypto/tls"
    "crypto/x509"
    "encoding/pem"
    "fmt"
    "io"
    "net/http"
    "os"

    "github.com/quic-go/quic-go/http3"
)

func main() {
    // load server's cert in PEM format
    certPem, err := os.ReadFile("cert.pem")
    if err != nil {
        panic(err)
    }

    // there is only one block CERTIFICATE in the cert file
    certRaw, _ := pem.Decode(certPem) // decode the PEM encoded cert
    cert, err := x509.ParseCertificate(certRaw.Bytes)
    if err != nil {
        panic(err)
    }
    // create a cert pool and add the server's cert
    certPool := x509.NewCertPool()
    certPool.AddCert(cert)

    tr := &http3.Transport{
        TLSClientConfig: &tls.Config{
            RootCAs: certPool, // use the cert pool with server's cert
        },
    }
    client := &http.Client{
        Transport: tr,
    }
    resp, err := client.Get("https://localhost:8080")
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    fmt.Printf("Response status: %s\n", resp.Status)
    body, err := io.ReadAll(resp.Body) // read the response body
    if err != nil {
        panic(err)
    }

    fmt.Printf("Response body: %s\n", body) // print the response body
}

Awesome work. You rock! 🚀

Recap

Today we learned how to write a simple HTTP/3 client, verify it works, and configure the client to trust the server’s certificate.

You can find more information in the official quic-go documentation.

Next Parts: