themsaid.com

Building a Secure Session Manager in Go

2025-03-10

Interactions between a website and its visitors are stateless, meaning each request from a visitor to the server is independent. Once the server sends a response, it has no built-in way to recognize if the same visitor makes another request.

This works well for static websites, where the server simply delivers content without needing to track individual visitors. Since the exchange is one-way, the server doesn't need to personalize responses or maintain visitor-specific information.

Dynamic websites operate differently, as they personalize much, if not all, of their content for each visitor. Take GitHub, for example: you log in once, and from that point on, GitHub recognizes you with every request. It tailors responses and actions to your individual needs.

So, how do web applications achieve this? You might think they track users by their IP address, but that wouldn’t be reliable. An IP address can change frequently as users may switch networks or browse through VPNs. Additionally, multiple users can share the same IP address, such as in offices, schools, or public networks. Because of these inconsistencies, relying on IP addresses alone wouldn’t guarantee accurate user identification.

Instead, web applications use cookies to identify and remember users. A cookie is a small piece of data that a website stores in the visitor’s browser. When you log in to a site, the server sends a unique identifier as a cookie, which the browser then includes with every subsequent request to that site. This allows the server to recognize you, maintain your session, and personalize content accordingly.

However, cookies come with two main limitations:

  1. Size limitation: Cookies have a maximum size of 4KB, which restricts how much data they can store.
  2. Security risks: Because cookies are stored in the user's browser, they can be manipulated or even stolen.

To address these limitations, web applications often use server-side sessions. Instead of storing large, potentially sensitive, data directly in cookies, the server generates a unique, random session ID for each user. This session ID is then stored in a cookie and sent to the user's browser.

With every request, the browser includes this session ID, allowing the server to retrieve the actual session data from a secure, server-side storage system. This storage can be a database, an in-memory data store like Redis or Memcached, or even a file-based system, depending on the application's needs.

Basic Session Operations

To illustrate how session exchange works between a visitor's browser (the client) and the web application (the server), let's implement a basic session manager:

import (
	"crypto/rand"
	"net/http"
)

var sessions = map[string]map[string]string{}

func Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cookie, _ := r.Cookie("session_id")

		if cookie != nil {
			session := sessions[cookie.Value]
		} else {
			session := map[string]string{}

			sessionId := rand.Text()

			sessions[sessionId] = session

			http.SetCookie(w, &http.Cookie{
				Name:  "session_id",
				Value: sessionId,
			})
		}

		next.ServeHTTP(w, r)
	})
}

In this example, we use a map to store all sessions, with each session identified by a random session ID. Within the middleware, we check the incoming request for a session cookie. If the cookie is present, we extract the session ID and retrieve the corresponding session from the session store. If no cookie is found, we assume the visitor is new, generate a fresh session for them, and include the session ID in the response as a cookie.

While this illustrates the full concept of session management, it lacks several aspects that make the implementation secure and scalable:

  1. Sessions never expire: Long-lived sessions increase the risk of session hijacking, as an attacker who steals a session can use it indefinitely. Additionally, as more visitors use the web application, their sessions accumulate in storage, leading to potential scalability issues as unused sessions remain indefinitely.
  2. Weak session ID generation: If the session ID is easy to guess, an attacker could use brute force attacks to impersonate a user. For example, the rand.Text() function generates a 26-character base32 string, an attacker could systematically guess session IDs and gain unauthorized access.
  3. Insecure session cookies: If cookies are not properly secured, attackers can exploit cross-site scripting (XSS) to inject malicious JavaScript and read or modify session cookies. Furthermore, if cookies are not restricted to HTTPS-only, they are transmitted in plain text over the network, making them vulnerable to man-in-the-middle (MITM) attacks.
  4. No protection against cross-site request forgery (CSRF): If the cookie is sent with every request, regardless of its origin, an attacker can trick a user into submitting a request (e.g., via a malicious form or script) that automatically includes their session cookie. Since the browser does not verify the request's origin, the server processes it as if it came from the legitimate user, allowing the attacker to perform unauthorized actions on their behalf.

In addition to these issues, the session is determined in the middleware but isn't passed down to the HTTP handlers in the request chain. This makes the session inaccessible to the web application's business logic. Furthermore, since sessions are stored in the application's memory, they are lost when the application is restarted, leading to potential data loss or the need for users to log in again.

Building a Secure Session Manager

To begin, let's define a type for our sessions:

type Session struct {
	createdAt      time.Time
	lastActivityAt time.Time
	id             string
	data           map[string]any
}

The session type includes two fields to store the timestamp of when the session was created and the timestamp of the last activity the user performed during the session. These timestamps enable us to expire sessions either after a certain period of user inactivity or once a specific session duration has passed.

Next, let's define an interface for the session storage medium:

type SessionStore interface {
	read(id string) (*Session, error)
	write(session *Session) error
	destroy(id string) error
	gc(idleExpiration, absoluteExpiration time.Duration) error
}

The SessionStore interface can be implemented by several types that manage sessions in different storage mediums. It defines four methods:

  1. read: reads the session that has the given unique ID.
  2. write: writes a session to the storage engine.
  3. destroy: deletes a session with the given ID.
  4. gc: performs garbage collection. It queries all expired sessions and deletes them from storage.

Now, let's define a SessionManager type that we'll use to coordinate all session operations:

type SessionManager struct {
	store              SessionStore
	idleExpiration     time.Duration
	absoluteExpiration time.Duration
	cookieName         string
}

The SessionManager should be instantiated on application startup and read configuration options from environment variables or terminal flags. We will use a minimal setup in this tutorial, but you may adjust your own implementation to control more aspects using configuration options.

Generating Session IDs

According to OWASP, session identifiers must have at least 64 bits of randomness. However, in most applications I've developed, the recommendation has been to use 256 bits of randomness, encoded into base64 character sets, for added security:

import (
	"crypto/rand"
	"encoding/base64"
	"io"
)

func generateSessionId() string {
	id := make([]byte, 32)

	_, err := io.ReadFull(rand.Reader, id)
	if err != nil {
		panic("failed to generate session id")
	}

	return base64.RawURLEncoding.EncodeToString(id)
}

Here, we start by creating a 32-byte buffer (32 × 8 = 256 bits) and filling it with random data using the crypto/rand package. Next, we encode the binary data using base64.RawURLEncoding, which produces a URL-safe string by removing special characters like / and +.

The final output is an 43-character base64 string containing letters (A-Z, a-z), digits (0-9), hyphens (-), and underscores (_). For example:

cOCMc1RV1m_uFZhkllsMLXPsJgFGc2YQ9GbdPy1hqHyA-

Working with The Session Type

To create a new session, we implement a constructor function:

func newSession() *Session {
	return &Session{
		id:             generateSessionId(),
		data:           make(map[string]any),
		createdAt:      time.Now(),
		lastActivityAt: time.Now(),
	}
}

The function calls the generateSessionId function we defined earlier to create a secure session ID. It also sets the timestamps to the current system time and initializes the map that will store session data.

Next, we'll implement three methods for reading and manipulating the session data:

func (s *Session) Get(key string) any {
	return s.data[key]
}

func (s *Session) Put(key string, value any) {
	s.data[key] = value
}

func (s *Session) Delete(key string) {
	delete(s.data, key)
}

For this tutorial, I've implemented only these three methods, but you can add as many as your application requires. Additionally, my implementation does not account for concurrent safety, as a session is tied to a single request by default. This means only one goroutine has access to the session at a time. However, if a handler spawns additional goroutines, I recommend passing session data as primitive values rather than sharing the session object itself. This approach simplifies data exchange and eliminates the need for costly mutex locks.

The Session Manager

To create a new session manager on application startup, we implement a constructor function:

func NewSessionManager(
	store SessionStore,
	gcInterval,
	idleExpiration,
	absoluteExpiration time.Duration,
	cookieName string) *SessionManager {

	m := &SessionManager{
		store:              store,
		idleExpiration:     idleExpiration,
		absoluteExpiration: absoluteExpiration,
		cookieName:         cookieName,
	}

	go m.gc(gcInterval)

	return m
}

The constructor function does two things:

  1. It creates a SessionManager instance.
  2. It calls a gc method in a goroutine and passes a gcInterval to it.

The gc method uses a ticker to run garbage collection at fixed intervals. On each tick, it calls the store's gc method, which is responsible for removing expired sessions:

func (m *SessionManager) gc(d time.Duration) {
	ticker := time.NewTicker(d)

	for range ticker.C {
		m.store.gc(m.idleExpiration, m.absoluteExpiration)
	}
}

Next, we'll implement a validate method that ensures a given session is valid for use:

func (m *SessionManager) validate(session *Session) bool {
	if time.Since(session.createdAt) > m.absoluteExpiration ||
		time.Since(session.lastActivityAt) > m.idleExpiration {
        
        // Delete the session from the store
		err := m.store.destroy(session.id)
		if err != nil {
			panic(err)
		}

		return false
	}

	return true
}

The method checks if the session has expired and returns true or false. If the session has expired, we delete it from the session store.

Next, we implement a start method that retrieves the session by reading the session cookie or generates a new one if needed. It then attaches the session to the request using context values:

func (m *SessionManager) start(r *http.Request) (*Session, *http.Request) {
	var session *Session

    // Read From Cookie
	cookie, err := r.Cookie(m.cookieName)
	if err == nil {
		session, err = m.store.read(cookie.Value)
		if err != nil {
			log.Printf("Failed to read session from store: %v", err)
		}
	}

    // Generate a new session
	if session == nil || !m.validate(session) {
		session = newSession()
	}

    // Attach session to context
	ctx := context.WithValue(r.Context(), sessionContextKey{}, session)
	r = r.WithContext(ctx)

	return session, r
}

We also add another method to save a session to the store after updating its lastActivityAt field:

func (m *SessionManager) save(session *Session) error {
	session.lastActivityAt = time.Now()

	err := m.store.write(session)
	if err != nil {
		return err
	}

	return nil
}

Finally, we add a migrate method that deletes an existing session and creates a new one with a fresh ID:

func (m *SessionManager) migrate(session *Session) error {
	session.mu.Lock()
	defer session.mu.Unlock()

	err := m.store.destroy(session.id)
	if err != nil {
		return err
	}

	session.id = generateSessionId()

	return nil
}

This method should be called whenever a user's privilege level changes during a session. For instance, when a guest user logs in, migrate is called to delete the old session and create a new one with a fresh ID. This prevents security risks such as session hijacking or session fixation by ensuring that any previously compromised session ID is invalidated, making it useless to an attacker.

The Session Middleware

With our session manager in place, we can now implement a middleware that handles session management efficiently. This middleware will:

  1. Retrieve the session ID from the session cookie.
  2. Load the corresponding session data from the store.
  3. Validate the session to ensure it is still active.
  4. Attach a valid session to the request context.
  5. Override the response writer to track writes and ensure the session cookie is properly included in the response.

To add the middleware, we'll add a method to the session manager called Handle:

func (m *SessionManager) Handle(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		
	})
}

The method accepts an http.Handler and returns a modified one.

Inside the middleware handler, the code looks like this:

// Start the session
session, rws := m.start(r)

// Create a new response writer
sw := &sessionResponseWriter{
    ResponseWriter: w,
    sessionManager: m,
    request:        rws,
}

// Add essential headers
w.Header().Add("Vary", "Cookie")
w.Header().Add("Cache-Control", `no-cache="Set-Cookie"`)

// Call the next handler and pass the new response writer and new request
next.ServeHTTP(sw, rws)

// Save the session
m.save(session)

// Write the session cookie to the response if not already written
writeCookieIfNecessary(sw)

The first step is to call the start method, which either retrieves the session from the incoming request cookie or generates a new one if none exists. It then attaches the session to the request context for use throughout the request lifecycle. Then, we create a custom response writer which we will discuss in a later section.

Next, we set the Vary header to Cookie and the Cache-Control header to no-cache="Set-Cookie".

These headers help maintain proper session handling and prevent caching issues that could lead to incorrect user data being displayed.

In the final steps of the method, we call the next handler in the chain, save the session after processing the request, and ensure the cookie is written to the response if it hasn’t been set already.

Custom Response Writer

The primary role of the custom sessionResponseWriter is to ensure the session cookie is written to the response before the response body and status code. Without this, the cookie would be ignored, causing users to receive a new session with every request.

We pass this custom writer to next.ServeHTTP in the middleware, allowing the next handler in the chain to use it for writing responses.

The session response writer looks like this:

type sessionResponseWriter struct {
	http.ResponseWriter
	sessionManager *SessionManager
	request        *http.Request
	done           bool
}

func (w *sessionResponseWriter) Write(b []byte) (int, error) {
	writeCookieIfNecessary(w)

	return w.ResponseWriter.Write(b)
}

func (w *sessionResponseWriter) WriteHeader(code int) {
	writeCookieIfNecessary(w)

	w.ResponseWriter.WriteHeader(code)
}

func (w *sessionResponseWriter) Unwrap() http.ResponseWriter {
	return w.ResponseWriter
}

We override the Write method, which is invoked when writing a response body, and the WriteHeader method, which sets the response status code. In each method, before executing the actual operation on the embedded http.ResponseWriter, we first call writeCookieIfNecessary to ensure the cookie is written.

We also define an Unwrap method that returns the original response writer. This method is used by the http.ResponseController type to retrieve the underlying writer when invoking methods like Flush, Hijack, SetReadDeadline, SetWriteDeadline, or EnableFullDuplex. You can learn more about this in this article.

Adding The Cookie To The Response

The writeCookieIfNecessary function looks like this:

func writeCookieIfNecessary(w *sessionResponseWriter) {
	if w.done {
		return
	}

	session, ok := r.Context().Value(sessionContextKey{}).(*Session)
	if !ok {
		panic("session not found in request context")
	}

	cookie := &http.Cookie{
		Name:     w.sessionManager.cookieName,
		Value:    session.id,
		Domain:   "mywebsite.com",
		HttpOnly: true,
		Path:     "/",
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
        Expires:  time.Now().Add(w.sessionManager.idleExpiration),
		MaxAge:   int(w.sessionManager.idleExpiration / time.Second),
	}

	http.SetCookie(w.ResponseWriter, cookie)

	w.done = true
}

It retrieves the session from the request context, generates a secure cookie, and writes it to the response. The done flag ensures that even if this function is called multiple times within the same request, the cookie is written only once.

The cookie created by this function has several security implications:

Since the cookie is included in every response, its expiration date is refreshed with each user request. However, if the user remains inactive for a certain period, the cookie will expire, prompting the session manager to generate a new session upon their next request.

Extracting the Session From Request

Within our application code, we’ll often need to retrieve the session from the request context. To simplify this, we’ll define a helper function:

func GetSession(r *http.Request) *Session {
	session, ok := r.Context().Value(sessionContextKey{}).(*Session)
	if !ok {
		panic("session not found in request context")
	}

	return session
}

Implementing an In-Memory Session Store

To give you an idea on how a session store implementation looks like, let's build an in-memory session store that persists sessions in a local map:

type InMemorySessionStore struct {
	mu       sync.RWMutex
	sessions map[string]*Session
}

func NewInMemorySessionStore() *InMemorySessionStore {
	return &InMemorySessionStore{
		sessions: make(map[string]*Session),
	}
}

The interface methods can be implemented as follow:

func (s *InMemorySessionStore) read(id string) (*Session, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	session, _ := s.sessions[id]

	return session, nil
}

func (s *InMemorySessionStore) write(session *Session) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	s.sessions[session.id] = session

	return nil
}

func (s *InMemorySessionStore) destroy(id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	delete(s.sessions, id)

	return nil
}

func (s *InMemorySessionStore) gc(idleExpiration, absoluteExpiration time.Duration) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	for id, session := range s.sessions {
		if time.Since(session.lastActivityAt) > idleExpiration ||
			time.Since(session.createdAt) > absoluteExpiration {
			delete(s.sessions, id)
		}
	}

	return nil
}

While this session store is fully functional, it is not suitable for production use. Since sessions are stored in the application's memory, they are lost whenever the application restarts. This can lead to data loss, forced user logouts, and a poor user experience. Additionally, as the number of active sessions grows, storing them in memory can lead to high memory usage, scalability issues, and potential performance bottlenecks.

For production environments, a more robust approach is to use a persistent session store such as a database (PostgreSQL, MySQL), an in-memory data store (Redis, Memcached), or a distributed session management system. These options provide better reliability, scalability, and resilience, ensuring that sessions persist across application restarts and can be efficiently managed across multiple instances in a load-balanced environment.

Using the Session Manager

During the startup of our application, we create a new instance of the SessionManager type by calling its constructor. If our sessions package is called sess, we may call the constructor as follow:

func main() {
	sessionManager := sess.NewSessionManager(
		sess.NewInMemorySessionStore(),
		30 * time.Minute,
		1 * time.Hour,
		12 * time.Hour,
		"session",
	)
}

Here, we configure an in-memory session store along with the following settings:

Then, we call the Handle method of the session manager on our server's multiplexer:

server := &http.Server{
    Addr:    ":8080",
    Handler: sessionManager.Handle(mux), // Here
}

server.ListenAndServe()

Inside our handlers, we can use the sess.GetSession function to retrieve the session instance from the request and read or modify its data as needed:

mux.HandleFunc("/projects/switch/some-project-id", func(w http.ResponseWriter, r *http.Request) {
    session := sess.GetSession(r)
    
    session.Put("current_project", "some-project-id")
})
mux.HandleFunc("/project", func(w http.ResponseWriter, r *http.Request) {
    session := sess.GetSession(r)
    
    currentProject := session.Get("current_project")
})

Wrapping Up

In this article, we explored the fundamentals of session management and built a session manager that aligns with the security recommendations of the Open Web Application Security Project (OWASP).

While our session manager follows best practices, additional measures are needed to fully protect against cross-site request forgery (CSRF) attacks.

Hi, I'm Mohamed Said.

I'm a software engineer, writer, and open-source contributor. You can reach me on Twitter/X @themsaid. Or send me an email at [email protected].