Skip to content

Users & Sessions

View as Markdown

HTTP is stateless by design. Your server has no idea if two requests were made by the same person. Cookies help with identifying, but anyone could edit a “user_id” cookie to pretend they’re someone else.

This is why sessions were created. When someone signs in, you create a session on the server and hand them an unguessable token. They send that token back with each request. If it matches, you know who they are.

  • Go 1.22+
  • A database connection via database/sql
  • nanoid package: github.com/matoous/go-nanoid/v2
  • HTTPS for sending secure cookies (set secure to false for local dev)

Use this structure for the snippets in this guide:

/auth
/sessions.go // Session model + create/validate/delete
/cookies.go // SetSessionCookie/ClearSessionCookie/GetSessionToken
/middleware.go // SessionMiddleware/GetSession/RequireSession (+ optional CSRF helper)
/main.go // Router + handlers

Sessions are comprised of an ID, a hashed secret, and a user ID for the corresponding user.

We split tokens into ID + secret so we can do a fast indexed lookup by ID, then verify proof-of-ownership by comparing the secret hash in constant time.

The secret is hashed before storage so that a database leak won’t immediately compromise all sessions.

CREATE TABLE sessions (
id TEXT NOT NULL PRIMARY KEY,
secret_hash BLOB NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL
);
CREATE INDEX sessions_user_id_idx ON sessions(user_id);

You define what data your users store. You need an ID at minimum but everything else is up to your app’s needs.

CREATE TABLE users (
id TEXT NOT NULL PRIMARY KEY,
created_at INTEGER NOT NULL
);

The token you send to clients looks like this:

<session_id>.<session_secret>

Both parts are random strings with at least 120 bits of entropy. The ID finds the session. The secret proves the client owns it.

We use go-nanoid to generate IDs:

Use this in auth/sessions.go and anywhere else you create IDs (for example users).

import gonanoid "github.com/matoous/go-nanoid/v2"
// For user IDs, 12 characters is plenty
userID, err := gonanoid.New(12)
// For session IDs and secrets, use 21 characters (the default)
sessionID, err := gonanoid.New()

Nanoid uses crypto/rand under the hood, so the IDs are cryptographically secure. A 21-character nanoid has about 126 bits of entropy. This is enough to prevent brute-force guessing and allow us to generate 1,000,000 IDs/second without worrying about collisions.

More info on this

When someone signs in, create a session and return the token:

Put this in auth/sessions.go:

package auth
import (
"context"
"crypto/sha256"
"database/sql"
"time"
gonanoid "github.com/matoous/go-nanoid/v2"
)
type Session struct {
ID string
SecretHash []byte
UserID string
CreatedAt time.Time
}
func CreateSession(ctx context.Context, db *sql.DB, userID string) (token string, err error) {
id, err := gonanoid.New()
if err != nil {
return "", err
}
secret, err := gonanoid.New()
if err != nil {
return "", err
}
secretHash := hashSecret(secret)
now := time.Now().UTC()
_, err = db.ExecContext(ctx,
"INSERT INTO sessions (id, secret_hash, user_id, created_at) VALUES (?, ?, ?, ?)",
id, secretHash, userID, now.Unix(),
)
if err != nil {
return "", err
}
return id + "." + secret, nil
}
func hashSecret(secret string) []byte {
hash := sha256.Sum256([]byte(secret))
return hash[:]
}

SHA-256 works fine here. Unlike passwords, the secret already has 120 bits of entropy. You can’t brute-force it. A fast hash is fine.

On each request, grab the token from the cookie, validate its format, look up the session, check expiration, and verify the secret.

Validate the token format before doing anything else. Reject tokens that don’t match the expected length. This prevents garbage input from hitting your database and keeps your logs clean.

Put this in auth/sessions.go:

package auth
import (
"context"
"crypto/subtle"
"database/sql"
"errors"
"strings"
"time"
)
const SessionExpiresIn = 24 * time.Hour
const sessionTokenPartLength = 21
var ErrInvalidSession = errors.New("invalid session")
func ValidateSession(ctx context.Context, db *sql.DB, token string) (*Session, error) {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return nil, ErrInvalidSession
}
sessionID, secret := parts[0], parts[1]
// Reject malformed tokens before touching the database
if len(sessionID) != sessionTokenPartLength || len(secret) != sessionTokenPartLength {
return nil, ErrInvalidSession
}
session, err := getSession(ctx, db, sessionID)
if err != nil {
return nil, err
}
if session == nil {
return nil, ErrInvalidSession
}
// Check expiration
if time.Since(session.CreatedAt) > SessionExpiresIn {
_ = DeleteSession(ctx, db, sessionID)
return nil, ErrInvalidSession
}
// Constant-time comparison
secretHash := hashSecret(secret)
if subtle.ConstantTimeCompare(secretHash, session.SecretHash) != 1 {
return nil, ErrInvalidSession
}
return session, nil
}
func getSession(ctx context.Context, db *sql.DB, sessionID string) (*Session, error) {
row := db.QueryRowContext(ctx,
"SELECT id, secret_hash, user_id, created_at FROM sessions WHERE id = ?",
sessionID,
)
var session Session
var createdAt int64
err := row.Scan(&session.ID, &session.SecretHash, &session.UserID, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
session.CreatedAt = time.Unix(createdAt, 0)
return &session, nil
}
func DeleteSession(ctx context.Context, db *sql.DB, sessionID string) error {
_, err := db.ExecContext(ctx, "DELETE FROM sessions WHERE id = ?", sessionID)
return err
}

subtle.ConstantTimeCompare prevents timing attacks. A regular comparison might return early on the first wrong byte, leaking info about how much was correct. Constant-time comparison takes the same time no matter where the mismatch is.

When someone changes their password or you spot suspicious activity, kill all their sessions:

Put this in auth/sessions.go:

func DeleteUserSessions(ctx context.Context, db *sql.DB, userID string) error {
_, err := db.ExecContext(ctx, "DELETE FROM sessions WHERE user_id = ?", userID)
return err
}

Send the token in an HttpOnly cookie. JavaScript can’t read HttpOnly cookies, which protects against XSS.

Put this in auth/cookies.go:

package auth
import (
"net/http"
)
const SessionCookieName = "session"
func SetSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
MaxAge: int(SessionExpiresIn.Seconds()),
HttpOnly: true,
Secure: true, // set to false for local testing
SameSite: http.SameSiteLaxMode,
})
}
func ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
func GetSessionToken(r *http.Request) string {
cookie, err := r.Cookie(SessionCookieName)
if err != nil {
return ""
}
return cookie.Value
}

What each attribute does:

  • HttpOnly: JavaScript can’t touch it
  • Secure: Only sent over HTTPS. Set this to false if you’re testing locally.
  • SameSite=Lax: Helps against CSRF while still allowing most top-level link navigations
  • Path=/: Works on all routes
  • MaxAge: Matches your session expiration

SameSite=Lax helps, but you should still check the Origin header on requests that change data:

Put this helper in auth/middleware.go (or split it into auth/csrf.go if you prefer).

package auth
import (
"net/http"
"net/url"
)
func VerifyRequestOrigin(r *http.Request, allowedOrigin string) bool {
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
return true
}
origin := r.Header.Get("Origin")
if origin == "" {
referer := r.Header.Get("Referer")
if referer == "" {
return false
}
u, err := url.Parse(referer)
if err != nil || u.Scheme == "" || u.Host == "" {
return false
}
origin = u.Scheme + "://" + u.Host
}
return origin == allowedOrigin
}

allowedOrigin should be the full origin (scheme://host[:port]), for example https://app.example.com.

Use this in middleware for any endpoint that modifies state.

Origin checking is a lightweight option when combined with SameSite cookies and same-origin APIs. For higher-risk apps or cross-site POST use cases, use a CSRF token instead (the synchronizer token pattern).

Here’s middleware that validates sessions and attaches them to the request context:

Put this in auth/middleware.go:

package auth
import (
"context"
"database/sql"
"net/http"
)
type contextKey string
const sessionContextKey contextKey = "session"
func SessionMiddleware(db *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := GetSessionToken(r)
if token == "" {
next.ServeHTTP(w, r)
return
}
session, err := ValidateSession(r.Context(), db, token)
if err != nil {
ClearSessionCookie(w)
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), sessionContextKey, session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetSession(ctx context.Context) *Session {
session, _ := ctx.Value(sessionContextKey).(*Session)
return session
}
func RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if GetSession(r.Context()) == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}

Put this in main.go:

import auth "github.com/.../auth"
func main() {
db, _ := sql.Open("sqlite3", "app.db")
mux := http.NewServeMux()
mux.HandleFunc("GET /", handleHome)
mux.HandleFunc("POST /login", handleLogin(db))
mux.HandleFunc("POST /logout", handleLogout(db))
mux.Handle("GET /dashboard", auth.RequireSession(http.HandlerFunc(handleDashboard)))
handler := auth.SessionMiddleware(db)(mux)
http.ListenAndServe(":8080", handler)
}
func handleLogin(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// ... validate credentials ...
token, err := auth.CreateSession(r.Context(), db, user.ID)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
auth.SetSessionCookie(w, token)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
}
func handleLogout(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := auth.GetSession(r.Context())
if session != nil {
_ = auth.DeleteSession(r.Context(), db, session.ID)
}
auth.ClearSessionCookie(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}

Request flow:

  1. POST /login creates a session and sets the cookie
  2. Browser sends cookie on later requests
  3. SessionMiddleware validates token and loads session
  4. Protected handlers use RequireSession and read session from context

Common gotchas:

  • Secure: true cookies are not sent over plain HTTP. If you need to test with HTTP then set secure to false.
  • If your app serves multiple subdomains, review cookie Domain and SameSite settings
  • Keep SessionExpiresIn and cookie MaxAge aligned (as shown here)

You’ve got the foundation now. If you want to implement email & password auth, head to Email & Password. If you want to use OAuth only instead, head to OAuth.