Securing Go REST Servers: Authentication Fundamentals

Previous installments of this series built REST servers without any security measures; deployed publicly, their entire APIs would be exposed. For many real-world services, at least parts of the API must be restricted to authenticated users. Before diving into implementation details, it helps to distinguish between the two concepts people usually mean when they say "auth."

  • Authentication (authn) — verifying that a client is a registered, known user of the API.
  • Authorization (authz) — determining what permissions different users have on the server.

A Unix-style file system makes a useful analogy. Authentication is logging in with your username and password. Authorization is the read-write-execute bits on files and directories: some files are private to certain users, some visible to groups, while "root" users get full access. This post focuses on authentication, since it's the more fundamental concept and the prerequisite on which authorization (which is inherently more use-case specific) can later be added.

TLS Is the Non-Negotiable Foundation

Whatever authentication scheme you choose for a public REST API, TLS must underpin it. TLS is the bedrock of public internet security, refined by years of counter-measures against real and potential threats. Never roll your own crypto. For REST servers over HTTP, HTTPS is required. Go's net/http supports HTTPS out of the box; a prior post in this series covers how to set it up.

Used over HTTPS, HTTP basic authentication is safe. The scheme itself — described in RFC 7617 — is problematic only when used over plaintext HTTP, where username/password pairs go over the wire masked by nothing more than base64 encoding. Once an HTTPS connection is established, however, all data between client and server is already protected; adding further cryptographic layers can make a system more vulnerable rather than less.

Basic auth works by having the server reply to an unauthenticated request with a WWW-Authenticate header; the client then retries with an Authorization header. The "Basic" scheme is straightforward to use from Go:

func main() {
  addr := flag.String("addr", ":4000", "HTTPS network address")
  certFile := flag.String("certfile", "cert.pem", "certificate PEM file")
  keyFile := flag.String("keyfile", "key.pem", "key PEM file")
  flag.Parse()

  mux := http.NewServeMux()
  mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
    if req.URL.Path != "/" {
      http.NotFound(w, req)
      return
    }
    fmt.Fprintf(w, "Proudly served with Go and HTTPS!\n")
  })

  mux.HandleFunc("/secret/", func(w http.ResponseWriter, req *http.Request) {
    user, pass, ok := req.BasicAuth()
    if ok && verifyUserPass(user, pass) {
      fmt.Fprintf(w, "You get to see the secret\n")
    } else {
      w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
      http.Error(w, "Unauthorized", http.StatusUnauthorized)
    }
  })

  srv := &http.Server{
    Addr:    *addr,
    Handler: mux,
    TLSConfig: &tls.Config{
      MinVersion:               tls.VersionTLS13,
      PreferServerCipherSuites: true,
    },
  }

  log.Printf("Starting server on %s", *addr)
  err := srv.ListenAndServeTLS(*certFile, *keyFile)
  log.Fatal(err)
}

net/http parses the basic-auth header natively. The Request.BasicAuth() method extracts the username and password. If verification fails, the handler returns HTTP 401 with the WWW-Authenticate header set to indicate basic authentication in the realm "api". The realm is arbitrary — a server-side description of the security domain — and its value has no protocol-level meaning beyond an implicit understanding between server and client.

mux.HandleFunc("/secret/", func(w http.ResponseWriter, req *http.Request) {
  user, pass, ok := req.BasicAuth()
  if ok && verifyUserPass(user, pass) {
    fmt.Fprintf(w, "You get to see the secret\n")
  } else {
    w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
    http.Error(w, "Unauthorized", http.StatusUnauthorized)
  }
})

The user-verification helper emulates checking credentials against a known set of users. In a production system, the usersPasswords map would be a database table. The critical detail is that passwords are stored using bcrypt — never in plaintext, so a database leak doesn't directly expose credentials. Bcrypt offers several protections by design:

  • Resistance to timing attacks, where an attacker could infer password information from verification duration.
  • Salting, protecting against rainbow-table brute-force attacks.
  • Deliberate slowness, making brute-force attacks generally harder.
var usersPasswords = map[string][]byte{
  "joe":  []byte("$2a$12$aMfFQpGSiPiYkekov7LOsu63pZFaWzmlfm1T8lvG6JFj2Bh4SZPWS"),
  "mary": []byte("$2a$12$l398tX477zeEBP6Se0mAv.ZLR8.LZZehuDgbtw2yoQeMjIyCNCsRW"),
}

// verifyUserPass verifies that username/password is a valid pair matching
// our userPasswords "database".
func verifyUserPass(username, password string) bool {
  wantPass, hasUser := usersPasswords[username]
  if !hasUser {
    return false
  }
  if cmperr := bcrypt.CompareHashAndPassword(wantPass, []byte(password)); cmperr == nil {
    return true
  }
  return false
}

When a user signs up, the bcrypt hash GenerateFromPassword of their password is calculated and stored; the plaintext is never kept. The hash is available via the x/crypto/bcrypt package, part of the extended standard library maintained mostly by the Go team. The VerifyUserPass logic used here matches that pattern.

Retrofitting the Task Server: Per-Path Auth Middleware

Taking the Gorilla middleware variant from part 5 of the series, the server can be equipped with HTTPS and basic auth with minimal changes. The bulk of the differences are in main:

$ go run /usr/local/go/src/crypto/tls/generate_cert.go --ecdsa-curve P256 --host localhost
2021/05/08 06:51:57 wrote cert.pem
2021/05/08 06:51:57 wrote key.pem

$ go run https-basic-auth-server.go
2021/05/08 06:52:16 Starting server on :4000

Three things change overall:

  1. New command-line flags specify the TLS certificate and key files.
  2. The handler for the "create new task" path gets wrapped with middleware.BasicAuth. Gorilla's routing lets you attach middleware on a per-path basis, which demonstrates how to require authentication for specific routes while leaving others open. The middleware attaches the authenticated username to the request context on success — the handler here doesn't use it, but it primes the server for authorization should different users later need different access rights on specific paths.
  3. The server is configured to serve HTTPS instead of HTTP.
$ curl --cacert cert.pem https://localhost:4000/
Proudly served with Go and HTTPS!

The middleware logic mirrors the earlier secret/ handler. The authdb.VerifyUserPass function it calls is exactly what one would use around a database table mapping users to their bcrypt-ed passwords.

Testing against a local HTTPS server follows a simple pattern. A curl request without authentication to a protected path gets a 401 response. Sending the same request with --user and base64 credentials succeeds. A Go client can use the Request.SetBasicAuth method, which encodes the username and password correctly, rather than assembling the header manually.

Why Not Sessions, Cookies, or JWT?

The simple approach here is deliberate. Authentication tooling around sessions, cookies, client- and server-side state is extensive, but in REST, every request should stand alone. Sessions fit poorly into that model. Basic authentication works well as a baseline; refinements are possible — for example, replacing passwords with tokens for ephemeral access or delegating authentication to an external identity provider via OAuth 2.0-style flows.

Earlier server variants in this series were insecure not just due to plain HTTP, but because they also lacked any notion of who was making requests. With HTTPS plus basic auth middleware, every one of those implementations can be secured, with the only external dependency being x/crypto/bcrypt.

After authentication is in place, authorization becomes a follow-on layer — deciding whether a permitted user is permitted to perform a given action on a given resource. That decision is inherently application-specific, but the foundation described here is what makes authorization possible in the first place.