Step 18 of 24

Authentication & authorization

Hash passwords with bcrypt or argon2id, run secure cookie sessions, choose between sessions and tokens, and enforce roles in Go.

18 min 8 sections

Authentication answers "who are you?" Authorization answers "what are you allowed to do?" Mistakes in either are among the most damaging bugs a backend can have, and most of them come from a few well-known traps. In this lesson you'll store passwords safely, log users in without leaking information, issue and check session cookies, weigh sessions against JWTs, and enforce permissions with middleware.

Storing passwords

Never store passwords in plain text, and never store them with a fast hash like SHA-256. When (not if) a database leaks, attackers run guesses against the hashes offline. A GPU computes billions of SHA-256 hashes per second, so most real-world passwords fall within hours.

Use a password hashing function, which is deliberately slow and salted. Each user gets a random salt, so identical passwords produce different hashes and precomputed tables are useless. The two sensible choices in Go are bcrypt and argon2id, both in golang.org/x/crypto.

go
const bcryptCost = 12

func HashPassword(password string) (string, error) {
	h, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
	if err != nil {
		return "", err // includes bcrypt.ErrPasswordTooLong for > 72 bytes
	}
	return string(h), nil
}

func CheckPassword(hash, password string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

The result is a self-describing string like $2a$12$R9h/cIPz0gi.URNNX3kh2O...: algorithm version, cost, salt and hash together, so you store one text column. The cost is exponential: each +1 doubles the work. Choose the highest cost that keeps a login check around 100–250 ms on your servers (12 is a common starting point; the library default is 10), and raise it as hardware improves. bcrypt only accepts up to 72 bytes of input, and Go's implementation returns an error rather than silently truncating.

argon2id is the modern recommendation. It is memory-hard: each guess needs a chunk of RAM, which makes GPU and custom-hardware attacks much more expensive. The x/crypto package gives you the raw function; you generate the salt and encode the parameters yourself:

go
// OWASP minimum parameters: 19 MiB of memory, 2 passes, 1 thread.
func HashArgon2id(password string) string {
	salt := make([]byte, 16)
	rand.Read(salt) // crypto/rand; never returns an error since Go 1.24
	key := argon2.IDKey([]byte(password), salt, 2, 19*1024, 1, 32)
	b64 := base64.RawStdEncoding.EncodeToString
	return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
		argon2.Version, 19*1024, 2, 1, b64(salt), b64(key))
}

To verify, parse the salt and parameters back out of the stored string, recompute IDKey with the candidate password, and compare with subtle.ConstantTimeCompare (never bytes.Equal, which returns early and leaks timing). Either algorithm is fine; what matters is not inventing your own.

Logging users in without leaking information

A login endpoint can reveal which emails have accounts, which helps attackers target them. Avoid two leaks:

  • Messages. Return the same invalid email or password whether the email is unknown or the password is wrong.
  • Timing. If unknown emails return instantly but known ones spend 200 ms in bcrypt, the difference is measurable. Compare against a dummy hash so both paths cost the same.
go
type User struct {
	ID           int64
	Email        string
	PasswordHash string
	Role         string
}

type UserStore interface {
	ByEmail(ctx context.Context, email string) (User, error)
}

var ErrInvalidCredentials = errors.New("invalid email or password")

// Compared against when the email is unknown, so both paths take the same time.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy-password"), bcryptCost)

func Authenticate(ctx context.Context, users UserStore, email, password string) (User, error) {
	u, err := users.ByEmail(ctx, email)
	if err != nil {
		bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
		return User{}, ErrInvalidCredentials
	}
	if !CheckPassword(u.PasswordHash, password) {
		return User{}, ErrInvalidCredentials
	}
	return u, nil
}

Also rate-limit login attempts per account and per IP address. Slow hashing protects stolen hashes; rate limiting protects the live endpoint from online guessing.

After a successful login, the server must recognise the user on later requests. The classic approach is a server-side session: generate a random token, store a record of it in the database, and send the token to the browser in a cookie.

sql
CREATE TABLE sessions (
    token_hash bytea PRIMARY KEY,
    user_id    bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    expires_at timestamptz NOT NULL
);

Store a SHA-256 hash of the token, not the token itself. If the table leaks, the hashes can't be replayed as cookies. (A fast hash is fine here: the token has 128 bits of randomness, so there is nothing to guess.)

go
type SessionStore interface {
	Create(ctx context.Context, tokenHash []byte, userID int64, expires time.Time) error
	User(ctx context.Context, tokenHash []byte) (User, error) // fails if missing or expired
	Delete(ctx context.Context, tokenHash []byte) error
}

const sessionCookie = "session"
const sessionTTL = 7 * 24 * time.Hour

func hashToken(token string) []byte {
	sum := sha256.Sum256([]byte(token))
	return sum[:]
}

func StartSession(w http.ResponseWriter, r *http.Request, sessions SessionStore, userID int64) error {
	token := rand.Text() // 26 base32 characters, at least 128 bits of randomness
	expires := time.Now().Add(sessionTTL)
	if err := sessions.Create(r.Context(), hashToken(token), userID, expires); err != nil {
		return err
	}
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookie,
		Value:    token,
		Path:     "/",
		Expires:  expires,
		HttpOnly: true,                 // JavaScript can't read it
		Secure:   true,                 // only sent over HTTPS
		SameSite: http.SameSiteLaxMode, // not sent on cross-site POSTs
	})
	return nil
}

crypto/rand.Text (Go 1.24) is the simplest way to get an unguessable token. Never use math/rand for anything security-related. The cookie flags each block an attack: HttpOnly stops injected scripts from stealing the token, Secure keeps it off plain HTTP, and SameSite=Lax stops the browser attaching it to cross-site form posts, which is the main defence against cross-site request forgery (CSRF). Go 1.25 adds http.CrossOriginProtection, middleware that rejects cross-origin unsafe requests using the browser's Sec-Fetch-Site header, as a second layer.

Logging out deletes the session row, which takes effect immediately. Deleting all of a user's rows signs them out on every device (after a password change, say). That ability to revoke is the main strength of server-side sessions.

Here is the whole flow, from the login form to a later request. Notice that the password crosses the network once, the database only ever holds hashes, and from then on the browser proves who it is with the cookie alone.

DiagramLogging in with a session cookie
BrowserServerDatabase1POST /login {email, password}2SELECT user by email3User + password hash4bcrypt compare5token := rand.Text()6INSERT session7OK8204 + Set-Cookie: session9Later: any request to the same site10GET /me + Cookie: session11Find session by hash12User 4213200 OK {"id": 42, ...}

Sessions versus tokens (JWT)

The alternative is a stateless token, usually a JSON Web Token: a base64url-encoded JSON payload such as {"sub":"42","role":"editor","exp":1791000000} plus a signature. Any server holding the key can verify it without a database lookup.

Server-side sessionJWT access token
Lookup per requestYes (fast indexed query, or cached)No: verify the signature
RevocationDelete the row: instantNot possible until exp, without extra machinery
Payload visibilityOpaque random stringAnyone can base64-decode the claims
Best fitBrowser apps talking to their own backendService-to-service calls, third-party API clients

For a web app and its own API, sessions are simpler and safer. If you do use JWTs: keep access tokens short-lived (5–15 minutes), pair them with a revocable refresh token stored server-side, pin the accepted signing algorithm, and always validate exp, iss and aud. Never put secrets in the payload; it's signed, not encrypted.

Middleware: who is calling?

Authentication belongs in middleware, so handlers can assume a user is present. The middleware reads the cookie, looks up the session, and stores the user in the request context under an unexported key type:

go
type ctxKey struct{}

func UserFrom(ctx context.Context) (User, bool) {
	u, ok := ctx.Value(ctxKey{}).(User)
	return u, ok
}

func RequireUser(sessions SessionStore, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		c, err := r.Cookie(sessionCookie)
		if err != nil {
			http.Error(w, "authentication required", http.StatusUnauthorized)
			return
		}
		u, err := sessions.User(r.Context(), hashToken(c.Value))
		if err != nil {
			http.Error(w, "authentication required", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, u)))
	})
}

Authorization: roles and ownership

Role-based access control (RBAC) gives each user a role and each role a set of permissions. Check permissions in code, not role names, so adding a role never means hunting through handlers:

go
var rolePermissions = map[string][]string{
	"viewer": {"articles:read"},
	"editor": {"articles:read", "articles:write"},
	"admin":  {"articles:read", "articles:write", "articles:delete", "users:manage"},
}

func Can(u User, perm string) bool {
	return slices.Contains(rolePermissions[u.Role], perm)
}

func RequirePermission(perm string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		u, ok := UserFrom(r.Context())
		if !ok {
			http.Error(w, "authentication required", http.StatusUnauthorized)
			return
		}
		if !Can(u, perm) {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

Roles aren't enough on their own. An editor may edit their own articles, not everyone's. Failing to check ownership is called an insecure direct object reference (IDOR): user 7 changes the URL from /articles/41 to /articles/42 and edits someone else's work. Enforce ownership in the query itself, UPDATE articles SET ... WHERE id = $1 AND author_id = $2, and return 404 when no row matches, so you don't even confirm the article exists.

Practice

Wire the pieces together with in-memory stores (a map of users by email and a map of sessions by token hash, guarded by a mutex):

  1. Seed [email protected] (role admin) and [email protected] (role viewer) with bcrypt hashes.
  2. Add POST /login (JSON email and password; 204 plus cookie on success, 401 otherwise), POST /logout, GET /me, and DELETE /v1/articles/{id} requiring articles:delete.
  3. Test with curl's cookie jar: curl -c jar -d '{...}' localhost:8080/login, then curl -b jar .... Ben should get 403 on DELETE; Ana should get 204. curl (and Chrome and Firefox) treat localhost as secure, so the Secure cookie works without TLS.
Show solution
go
type memStore struct {
	mu       sync.Mutex
	users    map[string]User // by email
	byID     map[int64]User
	sessions map[string]memSession // by string(tokenHash)
}

type memSession struct {
	userID  int64
	expires time.Time
}

func (m *memStore) ByEmail(_ context.Context, email string) (User, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	u, ok := m.users[email]
	if !ok {
		return User{}, errors.New("not found")
	}
	return u, nil
}

func (m *memStore) Create(_ context.Context, h []byte, userID int64, exp time.Time) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.sessions[string(h)] = memSession{userID, exp}
	return nil
}

func (m *memStore) User(_ context.Context, h []byte) (User, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	s, ok := m.sessions[string(h)]
	if !ok || time.Now().After(s.expires) {
		return User{}, errors.New("no session")
	}
	return m.byID[s.userID], nil
}

func (m *memStore) Delete(_ context.Context, h []byte) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	delete(m.sessions, string(h))
	return nil
}

func main() {
	store := &memStore{users: map[string]User{}, byID: map[int64]User{}, sessions: map[string]memSession{}}
	for i, s := range [][3]string{{"[email protected]", "correct horse battery", "admin"},
		{"[email protected]", "hunter2hunter2", "viewer"}} {
		hash, err := HashPassword(s[1])
		if err != nil {
			log.Fatal(err)
		}
		u := User{ID: int64(i + 1), Email: s[0], PasswordHash: hash, Role: s[2]}
		store.users[u.Email], store.byID[u.ID] = u, u
	}

	mux := http.NewServeMux()
	mux.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
		var in struct{ Email, Password string }
		if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
			http.Error(w, "bad request", http.StatusBadRequest)
			return
		}
		u, err := Authenticate(r.Context(), store, in.Email, in.Password)
		if err != nil {
			http.Error(w, err.Error(), http.StatusUnauthorized)
			return
		}
		if err := StartSession(w, r, store, u.ID); err != nil {
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}
		w.WriteHeader(http.StatusNoContent)
	})
	mux.Handle("POST /logout", RequireUser(store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		c, _ := r.Cookie(sessionCookie) // RequireUser guarantees it exists
		store.Delete(r.Context(), hashToken(c.Value))
		http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1})
		w.WriteHeader(http.StatusNoContent)
	})))
	mux.Handle("GET /me", RequireUser(store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		u, _ := UserFrom(r.Context())
		fmt.Fprintf(w, "%s (%s)\n", u.Email, u.Role)
	})))
	mux.Handle("DELETE /v1/articles/{id}", RequireUser(store, RequirePermission("articles:delete",
		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.WriteHeader(http.StatusNoContent)
		}))))
	log.Fatal(http.ListenAndServe("localhost:8080", mux))
}
text
GET /me without cookie         -> 401
POST /login as ben             -> 204, Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
GET /me as ben                 -> [email protected] (viewer)
DELETE /v1/articles/1 as ben   -> 403
DELETE /v1/articles/1 as ana   -> 204
POST /logout, then GET /me     -> 401

Key takeaways

  • Hash passwords with bcrypt (cost 12 or more) or argon2id; never with SHA-256 or a home-made scheme.
  • Give identical errors and timing for unknown emails and wrong passwords, and rate-limit login attempts.
  • Sessions: a crypto/rand token in an HttpOnly, Secure, SameSite=Lax cookie, with only its SHA-256 hash stored server-side.
  • Prefer sessions for browser apps (instant revocation); use short-lived JWTs with refresh tokens for service and API clients.
  • Authenticate in middleware and put the user in the context; return 401 for no identity and 403 for no permission.
  • Check permissions rather than role names, and enforce ownership in the query to prevent IDOR.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Why is SHA-256 a poor choice for storing passwords, even with a salt?

  2. 2.Why does Authenticate run bcrypt.CompareHashAndPassword against a dummy hash when the email doesn't exist?

  3. 3.What is the main advantage of server-side sessions over stateless JWT access tokens?

  4. 4.User 7, an editor, sends PATCH /articles/42 for an article written by user 9. The permission check for articles:write passes. What prevents the edit?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.