In this post, we will learn how to authenticate users using session cookies in a Golang server application.
When a user logs into our application, we need to know who they are across all our HTTP methods and routes.
One way to do this is to store the users “session”. A session is started once a user logs in, and expires some time after that.
Each logged in user has some reference to the session (called a cookie), which they send with their requests. We then use this reference to look up the user that it belongs to and return information specific to them.
If you just want to see the source code for this tutorial, you can find it here
Overview
In this post, we will look at how to create and store the session of a logged in user as a cookie on the browser.
We will build an application with a /signin
and a /welcome
route.
- The
/signin
route will accept a users username and password, and set a session cookie if successful. - The
/welcome
route will be a simple HTTPGET
route which will show a personalized message to the currently logged in user.
The session information of the user will be stored in our applications local memory.
We will also assume that the users that are signing in have already created their username-password credentials on our application.
If you want to know how to implement user registration, you can read my post on password authentication and storage in Go.
Creating the HTTP Server
Let’s start by initializing the HTTP server with the required routes:
package main
import (
"log"
"net/http"
)
func main() {
// "Signin" and "Welcome" are handlers that we have to implement
http.HandleFunc("/signin", Signin)
http.HandleFunc("/welcome", Welcome)
// start the server on port 8080
log.Fatal(http.ListenAndServe(":8080", nil))
}
We can now define the Signin
and Welcome
routes.
Creating Session Tokens
We will be creating a new session token every time a user signs in.
The /signin
route will take the users credentials and log them in.
In order to make this simple, we’re storing the users information as an in-memory map in our code:
var users = map[string]string{
"user1": "password1",
"user2": "password2",
}
For now, there are only two valid users in our application: user1
, and user2
.
If you want a more production-ready method to store passwords, you can read my post on password authentication and storage in Go
We also need to define a map to store information about each users session:
// this map stores the users sessions. For larger scale applications, you can use a database or cache for this purpose
var sessions = map[string]session{}
// each session contains the username of the user and the time at which it expires
type session struct {
username string
expiry time.Time
}
// we'll use this method later to determine if the session has expired
func (s session) isExpired() bool {
return s.expiry.Before(time.Now())
}
Next, we can implement the Signin
HTTP handler:
// Create a struct that models the structure of a user in the request body
type Credentials struct {
Password string `json:"password"`
Username string `json:"username"`
}
func Signin(w http.ResponseWriter, r *http.Request) {
var creds Credentials
// Get the JSON body and decode into credentials
err := json.NewDecoder(r.Body).Decode(&creds)
if err != nil {
// If the structure of the body is wrong, return an HTTP error
w.WriteHeader(http.StatusBadRequest)
return
}
// Get the expected password from our in memory map
expectedPassword, ok := users[creds.Username]
// If a password exists for the given user
// AND, if it is the same as the password we received, the we can move ahead
// if NOT, then we return an "Unauthorized" status
if !ok || expectedPassword != creds.Password {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Create a new random session token
// we use the "github.com/google/uuid" library to generate UUIDs
sessionToken := uuid.NewString()
expiresAt := time.Now().Add(120 * time.Second)
// Set the token in the session map, along with the session information
sessions[sessionToken] = session{
username: creds.Username,
expiry: expiresAt,
}
// Finally, we set the client cookie for "session_token" as the session token we just generated
// we also set an expiry time of 120 seconds
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: sessionToken,
Expires: expiresAt,
})
}
If a user logs in successfully, this handler will set a cookie on the client side, and inside its own local memory. Once a cookie is set on a client, it is sent along with every subsequent request.
We can use UUIDs to represent session tokens, since they are uniform in structure, and difficult to guess.
Authenticating Users Through Session Cookies
Now that we have persisted the users session information on their client (in the form of the session_token
cookie) and the server, we can write our “welcome” handler to handle user specific information.
Since logged in clients have session information stored on their end as cookies, we can use it to:
- Authenticate subsequent user requests
- Get information about the user making the request
Let’s write our Welcome
handler to do that:
func Welcome(w http.ResponseWriter, r *http.Request) {
// We can obtain the session token from the requests cookies, which come with every request
c, err := r.Cookie("session_token")
if err != nil {
if err == http.ErrNoCookie {
// If the cookie is not set, return an unauthorized status
w.WriteHeader(http.StatusUnauthorized)
return
}
// For any other type of error, return a bad request status
w.WriteHeader(http.StatusBadRequest)
return
}
sessionToken := c.Value
// We then get the session from our session map
userSession, exists := sessions[sessionToken]
if !exists {
// If the session token is not present in session map, return an unauthorized error
w.WriteHeader(http.StatusUnauthorized)
return
}
// If the session is present, but has expired, we can delete the session, and return
// an unauthorized status
if userSession.isExpired() {
delete(sessions, sessionToken)
w.WriteHeader(http.StatusUnauthorized)
return
}
// If the session is valid, return the welcome message to the user
w.Write([]byte(fmt.Sprintf("Welcome %s!", userSession.username)))
}
From the code, we can see that our welcome handler gives us an “unauthorized” (or 401
) status under certain circumstances:
- If there is no
session_token
cookie along with the request (which means that the requestor hasn’t logged in) - If the session token is not present in memory (which means that the requestor is sending us an invalid session token)
- If the session has expired
Session based authentication keeps your users sessions secure in a couple of ways:
- Since the session tokens are randomly generated, its near-impossible for a malicious user to brute-force their way into a users session.
- If a users session token is compromised somehow, it cannot be used after its expiry. This is why the expiry time is restricted to small intervals (a few seconds to a couple of minutes)
Refreshing Session Tokens
Since the expiry time of a session token is kept small, we need to issue a new token often to keep the user logged in.
Of course, we cannot expect the user to login every time their token expires. To solve this, we can create another route that accepts the users current session token, and issues a new session token with a renewed expiry time.
Lets define a Refresh
HTTP handler to renew the users session token every time they hit the /refresh
route in our application
func Refresh(w http.ResponseWriter, r *http.Request) {
// (BEGIN) The code from this point is the same as the first part of the `Welcome` route
c, err := r.Cookie("session_token")
if err != nil {
if err == http.ErrNoCookie {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusBadRequest)
return
}
sessionToken := c.Value
userSession, exists := sessions[sessionToken]
if !exists {
w.WriteHeader(http.StatusUnauthorized)
return
}
if userSession.isExpired() {
delete(sessions, sessionToken)
w.WriteHeader(http.StatusUnauthorized)
return
}
// (END) The code until this point is the same as the first part of the `Welcome` route
// If the previous session is valid, create a new session token for the current user
newSessionToken := uuid.NewString()
expiresAt := time.Now().Add(120 * time.Second)
// Set the token in the session map, along with the user whom it represents
sessions[newSessionToken] = session{
username: userSession.username,
expiry: expiresAt,
}
// Delete the older session token
delete(sessions, sessionToken)
// Set the new token as the users `session_token` cookie
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: newSessionToken,
Expires: time.Now().Add(120 * time.Second),
})
}
We can now add this to the rest of our routes:
http.HandleFunc("/refresh", Refresh)
Logging Out Our Users
If the user decides to logout of our application, we need to remove their session token from our storage as well as the users client.
Let’s create a Logout
handler to implement this:
func Logout(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session_token")
if err != nil {
if err == http.ErrNoCookie {
// If the cookie is not set, return an unauthorized status
w.WriteHeader(http.StatusUnauthorized)
return
}
// For any other type of error, return a bad request status
w.WriteHeader(http.StatusBadRequest)
return
}
sessionToken := c.Value
// remove the users session from the session map
delete(sessions, sessionToken)
// We need to let the client know that the cookie is expired
// In the response, we set the session token to an empty
// value and set its expiry as the current time
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: "",
Expires: time.Now(),
})
}
We can now add this to the rest of our routes:
http.HandleFunc("/logout", Logout)
Running Our Application
To run this application, build and run the Go binary:
go build
./go-session-auth-example
Now, using any HTTP client with support for cookies (like Postman, or your web browser) make a sign-in request with the appropriate credentials:
POST http://localhost:8080/signin
{"username":"user2","password":"password2"}
You can now try hitting the welcome route from the same client to get the welcome message:
GET http://localhost:8080/welcome
Hit the refresh route, and then inspect the clients cookies to see the new value of the session_token
:
POST http://localhost:8080/refresh
Finally, call the logout route to clear session data:
GET http://localhost:8080/logout
Calling the welcome and refresh routes after this will result in a 401
error.
You can find the working source code for this example on Github.