Authentication for REST APIs in Go

Written 16th of September 2020.

A picture of a cookie.

Mmmm, cookie. Photo by Luis Rivera.

There are many options and opinions on how to add authentication and authorization to an HTTP REST API in Go. I was looking for something to let me provide simple username and password-based authentication in my web projects, no third-parties involved. In this post, I will provide the solution that I found to be the simplest, most battle-tested of all, using:

  • Server-side session data storage
  • Secure cookies for session id storage on the client
  • Bcrypt for password hashing and comparison

I've provided an example project, which is available at github.com/maragudk/rest-auth-server.

I haven't looked at password-less solutions, such as sending a magic link to customers, but I'd like to in the future. Also, no two-factor authentication for now.

Why sessions and cookies?

Let me start by talking about solutions I didn't pick.

In previous projects, I've been using external auth providers, such as Auth0, to fill my authentication needs. However, it didn't sit well with me to outsource something so central like the user database, and I started looking for other solutions.

A solution based on JWT (JSON Web Tokens) seems to be very popular. However, apparently there is no shortage of problems with the spec. I'm no cryptographer, but in my experience, unnecessary complexity and unsafe defaults ("none" hashing algorithm anyone?) is a bad thing, so I tried to steer clear of this standard.

Other obvious contenders would be the OAuth standards, but just understanding the terminology takes some effort, never mind implementing it (with libraries, of course, but still). I was going for something simpler.

I've been reading the excellent OWASP Authentication cheat sheet for guidance on a modern, secure authentication solution. Also, I've been looking at what various frameworks in other languages are doing.

That's why I ended up concluding that good old session cookies, with modern settings, are actually a great solution. What are cookies good for?

  • They've been around forever. Basically every tool supports them.
  • They're just HTTP headers.
  • The Set-Cookie header has newer properties, such as Secure, HttpOnly, and SameSite, that make them considerably harder to exploit.
  • They are (of course) delivered over now-ubiquitous HTTPS.

Because the session data is stored server-side, you can store whatever you need, without thinking about the 4kB cookie data limit. It's also possible to invalidate sessions if you need to, something that's not possible with JWT without introducing additional server-side state.

And why bcrypt? Well, it's secure, modern, and recommended on the OWASP password storage cheat sheet. Also, it has a nice and simple implementation in the Go stdlib.

I love it, how do I begin?

Like I mentioned in the beginning, I've provided an example project at github.com/maragudk/rest-auth-server, which you should check out. Here are the highlights.

Setup

The session manager I use is github.com/alexedwards/scs. It's easy to setup with some middleware:

import "github.com/alexedwards/scs/v2"

sm := scs.New()
sm.Cookie.Secure = true
mux.Use(sm.LoadAndSave)

(I'm using chi for easier routing, but you can plug in the middleware with your favourite router or the stdlib.)

Signup

For signup, we accept a username and password, hash the password with bcrypt with the default cost (no salting is necessary for bcrypt), and store the username and password in storage. In this case, the storage is in-memory, but replace with your favourite database.

Note that bcrypt has a max password length, so we restrict it to 64 characters.

import "golang.org/x/crypto/bcrypt"

const (
  minPasswordLength = 10
  maxPasswordLength = 64
)

func (s *Storer) Signup(name, password string) error {
  passwordLength := utf8.RuneCountInString(password)
  if passwordLength < minPasswordLength || passwordLength > maxPasswordLength {
    return fmt.Errorf("%v is outside the password length range of [%v,%v]", passwordLength, minPasswordLength, maxPasswordLength)
  }

  hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
  if err != nil {
    return fmt.Errorf("could not hash password: %w", err)
  }

  user := model.User{
    Name:     name,
    Password: hashedPassword,
  }

  s.users[name] = &user
  return nil
}

Login

Login is pretty simple as well. Get the username and password hash out from your storage, and compare using Go's built-in safe comparison bcrypt.CompareHashAndPassword, so you don't have to think about timing attacks.

func (s *Storer) Login(name, password string) (*model.User, error) {
  user, ok := s.users[name]
  if !ok {
    return nil, nil
  }

  if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
    if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
      return nil, nil
    }
    return nil, err
  }

  user.Password = nil
  return user, nil
}

Somewhere in your setup code, make sure to register your User model with gob.Register(model.User{}). Then, the important parts of the handler are:

user, err := repo.Login(name, password)
if err != nil {
  http.Error(w, err.Error(), http.StatusBadGateway)
  return
}
if user == nil {
  http.Error(w, "email and/or password incorrect", http.StatusForbidden)
  return
}

if err := s.RenewToken(r.Context()); err != nil {
  http.Error(w, err.Error(), http.StatusInternalServerError)
  return
}
s.Put(r.Context(), sessionUserKey, user)

Note the RenewToken part of the session manager, which is provided by scs. It's to avoid session fixation attacks.

Logout

Logout is the simplest part, we just need to destroy the session. In your handler:

if err := s.Destroy(r.Context()); err != nil {
  http.Error(w, err.Error(), http.StatusInternalServerError)
}

Protecting routes

Finally, we've arrived at the part that we really care about: protecting our routes.

I've written some middleware (which takes and returns an http.Handler), called Authorize. To protect routes, you can use it like this:

mux.Group(func(r chi.Router) {
  r.Use(handlers.Authorize(s.sm))

  r.Get("/check", handlers.CheckSessionHandler())
})

Authorize checks for our key in the session manager, and returns 401 Unauthorized if it isn't present:

if !s.Exists(r.Context(), sessionUserKey) {
  http.Error(w, "unauthorized, please login", http.StatusUnauthorized)
  return
}

user := s.Get(r.Context(), sessionUserKey).(model.User)
ctx := context.WithValue(r.Context(), sessionUserKey, user)
next.ServeHTTP(w, r.WithContext(ctx))

…and that's basically it!

Epilogue

I hope you found this little authentication how-to useful. I believe this is a pretty good base to build your authentication and authorization on. Of course, you probably want to expand your user model to include stuff like roles (user, moderator, admin) or other types of permissions, but that's for another article.

See some additional information on the not-chosen JWT in the Reddit discussion.

About me

I’m Markus, a professional software consultant and developer. 🤓✨ You can reach me at [email protected].

I'm currently building Go courses over at golang.dk.