When the API demands proof
You are building a service that needs to fetch user data from a third-party API. You have the access token sitting in your environment variables. You fire off a request, and the server immediately replies with a 401 Unauthorized. The API documentation says to send a Bearer token, but Go's net/http package does not have a built-in SetBearerToken method. You have to attach it yourself, exactly where the HTTP specification expects it.
The wristband analogy
A Bearer token is just a string that proves you have permission to access a resource. Think of it like a concert wristband. The venue staff does not check your ID at the door. They just scan the wristband. If it matches the batch they printed, you get in. In HTTP, the wristband goes into the Authorization header. The server reads that header, validates the token against its database or signing key, and decides whether to serve the data or reject the request. Go treats headers as plain key-value pairs. It does not hide the mechanics behind a magic authentication method. You set the header, and the transport layer sends it.
Headers are just strings. The protocol is explicit. The server expects the exact prefix Bearer followed by the token value. Miss the space, drop the prefix, or use the wrong casing, and the validation step fails before your code ever touches the response body.
Set the header exactly once per request. Trust the transport to do the rest.
The minimal request
Here is the simplest way to attach a token to a request. You create the request object, set the header, and execute it.
package main
import (
"fmt"
"net/http"
)
func main() {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
// NewRequest builds the request struct but does not send it yet.
req, err := http.NewRequest(http.MethodGet, "https://api.example.com/data", nil)
if err != nil {
fmt.Println(err)
return
}
// The Authorization header requires the exact "Bearer " prefix.
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
// Do executes the request and blocks until the response arrives.
resp, err := client.Do(req)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
fmt.Println(resp.Status)
}
The code above works, but it leaves error handling to chance and ignores context cancellation. Production code needs structure.
Keep the request creation separate from execution. Verify the response before parsing.
What happens under the hood
When http.NewRequest runs, it allocates a Request struct and populates the method, URL, and header map. The Header field is a http.Header, which is just a map of strings to string slices. Calling Set replaces any existing value for that key. When client.Do runs, Go serializes the struct into raw HTTP bytes. It writes the method and path, adds the Host header, writes every entry in the header map, and sends it over the wire.
The transport layer handles the heavy lifting. Go's default http.Transport maintains a pool of idle TCP connections. It reuses them across requests to the same host. It negotiates TLS, handles HTTP/2 multiplexing, and respects Connection: close headers. Your Bearer token travels inside the plaintext header section of the HTTP request. If you use HTTPS, the entire payload gets encrypted before leaving your machine. The server decrypts it, parses the headers, extracts the Authorization value, strips the Bearer prefix, and validates the remaining string.
If validation passes, the server returns 200 OK. If it fails, you get 401 Unauthorized or 403 Forbidden. The transport layer does not retry failed authentication by default. It returns the response object to your code. You are responsible for reading the status code and deciding what to do next.
The standard library gives you control, not convenience. Use it deliberately.
Production-ready patterns
In production, you rarely hardcode tokens or repeat header setup across five different functions. You wrap the logic in a reusable client or middleware. You also need to handle context cancellation and proper error wrapping. Go convention dictates that functions accepting a context take it as the first parameter, named ctx. Error handling stays explicit. The community accepts the if err != nil boilerplate because it forces you to acknowledge failure paths. You do not swallow errors. You wrap them with fmt.Errorf and %w so callers can inspect the chain.
package main
import (
"context"
"fmt"
"io"
"net/http"
)
// FetchData sends an authenticated GET request and returns the body.
func FetchData(ctx context.Context, token string) ([]byte, error) {
// Context carries deadlines and cancellation signals.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/data", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Attach the token exactly as the spec requires.
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// Check for HTTP errors before reading the body.
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %s", resp.Status)
}
// Read the entire body into memory.
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read body: %w", err)
}
return body, nil
}
The function above follows Go idioms. The receiver name convention does not apply here because this is a package-level function, but if you moved it to a struct, you would name the receiver c or cli, not this. The context.Context parameter sits first. The error wrapping preserves the original cause. The body closes via defer to prevent connection leaks.
Run gofmt on save. Do not argue about indentation. The tool decides, and the rest of the ecosystem expects it.
Write functions that take context first. Wrap errors explicitly. Close bodies immediately.
Managing token lifecycle
OAuth2 tokens expire. A static string works for testing, but production systems need rotation. The standard approach is to fetch a fresh token when the old one expires, or to refresh it proactively before the deadline. You can implement this with a background goroutine that watches the expiration timestamp, or you can intercept 401 responses and retry with a new token.
Go's http.RoundTripper interface makes interception clean. You implement RoundTrip(req *http.Request) (*http.Response, error), call the underlying transport, check the status code, and swap the token if needed. This keeps authentication logic out of your business functions.
// AuthTransport wraps an http.RoundTripper to inject tokens automatically.
type AuthTransport struct {
base http.RoundTripper
getToken func() (string, error)
}
// RoundTrip executes the request and retries once on 401.
func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid mutating shared state.
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("Authorization", "Bearer "+t.getToken())
resp, err := t.base.RoundTrip(reqCopy)
if err != nil {
return nil, err
}
// Return immediately if the server accepted the token.
if resp.StatusCode != http.StatusUnauthorized {
return resp, nil
}
// Refresh the token and retry exactly once.
newToken, err := t.getToken()
if err != nil {
return nil, fmt.Errorf("token refresh failed: %w", err)
}
reqCopy.Header.Set("Authorization", "Bearer "+newToken)
return t.base.RoundTrip(reqCopy)
}
The transport clones the request to prevent data races. It calls the underlying transport, checks for 401, refreshes, and retries. This pattern scales to dozens of API calls without duplicating header logic.
Accept interfaces, return structs. Let callers decide how to store tokens.
Common traps and compiler signals
The most common mistake is dropping the Bearer prefix. The server expects the exact string Bearer <token>. If you pass just the token, the server rejects it with a 401 Unauthorized response. Another frequent issue is mutating a shared request object. If you reuse a *http.Request across multiple calls and change the header in place, concurrent goroutines will race over the header map. The compiler will not catch this. You will see data races at runtime. Always create a fresh request per call, or clone it safely.
Forgetting to check the response status code is another trap. client.Do returns a successful *http.Response even when the server replies with 400, 401, or 500. The err value only captures network failures, DNS resolution errors, or TLS handshake failures. If you skip the status check, your program will happily try to parse an HTML error page as JSON. The compiler complains with cannot use x (type string) as type []byte in argument if you try to pass the wrong type to a JSON decoder, but it will never warn you about a 401 response. You have to write that check yourself.
Logging the full request object is dangerous. If you print req or resp directly, the token travels to your logs in plaintext. Production systems strip sensitive headers before logging. Use structured logging and explicitly exclude Authorization from the output.
The compiler also rejects unused imports with imported and not used. If you import context but never pass ctx to your functions, the build fails. This strictness prevents dead code from accumulating. Trust the compiler to catch structural mistakes. Focus your attention on logic and state management.
Never log raw requests. Always clone before mutation. Check status codes before parsing.
Choosing your approach
Use raw net/http with manual header setup when you need a single authenticated call and want zero dependencies. Use a custom http.RoundTripper or middleware when you need to attach tokens to every request in a service automatically. Use a dedicated HTTP client library like go-resty or httpx when your team prefers fluent APIs and built-in retry logic. Use database-specific OAuth providers like pgx's OAuthTokenProvider when your database driver supports dynamic token injection during the connection handshake. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Pick the tool that matches your scope. Do not overengineer a one-off script. Do not underengineer a service that handles thousands of requests per minute.