# Authentication & authorization

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

Backend with Go roadmap · Production · 18 min read · https://levelcode.org/roadmaps/go-backend/auth

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.

## Sessions: a random token in a cookie

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.

```sequence
title: Logging in with a session cookie
participants: Browser, Server, Database
Browser -> Server: POST /login {email, password}
> Sent over HTTPS, so the password is encrypted in transit. This is the only time it leaves the browser.
Server -> Database: SELECT user by email
Database --> Server: User + password hash
> An unknown email still costs a full hash comparison against dummyHash, so the response time gives nothing away.
Server -> Server: bcrypt compare
> bcrypt.CompareHashAndPassword is deliberately slow (around 100 to 250 ms). A wrong password returns 401 with the same message as an unknown email.
Server -> Server: token := rand.Text()
> crypto/rand.Text returns 26 random base32 characters: the session token, unguessable at 128 bits.
Server -> Database: INSERT session
> The row holds sha256(token), the user ID and the expiry. Only the hash of the token is stored, so a leaked sessions table can't be replayed as cookies.
Database --> Server: OK
Server --> Browser: 204 + Set-Cookie: session
> The cookie is HttpOnly (page scripts can't read it), Secure (HTTPS only) and SameSite=Lax (not sent on cross-site POSTs).
note Browser, Database: Later: any request to the same site
Browser -> Server: GET /me + Cookie: session
> The browser attaches the cookie automatically. The application code in the page never touches it.
Server -> Database: Find session by hash
> The server hashes the cookie value with SHA-256 and looks for an unexpired row.
Database --> Server: User 42
> RequireUser stores the user in the request context. No row, or an expired one, means 401.
Server --> Browser: 200 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 session | JWT access token |
|---|---|---|
| Lookup per request | Yes (fast indexed query, or cached) | No: verify the signature |
| Revocation | Delete the row: instant | Not possible until `exp`, without extra machinery |
| Payload visibility | Opaque random string | Anyone can base64-decode the claims |
| Best fit | Browser apps talking to their own backend | Service-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.

> [!WARNING]
> Don't store tokens in `localStorage`. Any XSS bug can read it and send the token anywhere. An `HttpOnly` cookie is out of reach of page scripts.

## 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.

> [!IMPORTANT]
> Authorize on the server for every request. Hiding a button in the UI is not access control; anyone can send the HTTP request directly.

## 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 `ana@example.com` (role `admin`) and `ben@example.com` (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.

**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{{"ana@example.com", "correct horse battery", "admin"},
		{"ben@example.com", "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                 -> ben@example.com (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.

## Quiz

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

   A. SHA-256 has known collisions that let attackers log in
   B. It is too fast: attackers can test billions of guesses per second against leaked hashes
   C. Its output is too long to store in a database column
   D. It cannot be used with a salt

   **Answer: B.** Password hashes are attacked offline by guessing. bcrypt and argon2id are deliberately slow (and argon2id memory-hard), so each guess is expensive. SHA-256 is designed to be fast, which is exactly wrong for passwords.

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

   A. To warm up the bcrypt implementation for the next request
   B. bcrypt panics if it isn't called on every request
   C. So the response time doesn't reveal whether the email has an account
   D. To automatically create an account for the unknown email

   **Answer: C.** Without it, unknown emails return in microseconds while known ones take the full bcrypt time. That timing difference lets attackers enumerate registered accounts.

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

   A. A session can be revoked instantly by deleting its record
   B. Sessions don't need cookies
   C. Sessions require no database or cache lookup
   D. Sessions can carry more user data in the token

   **Answer: A.** Deleting the session row ends that session immediately, and deleting all of a user's rows signs them out everywhere. A JWT stays valid until it expires unless you add a denylist, which reintroduces the lookup that JWTs were meant to avoid.

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?

   A. The SameSite=Lax cookie attribute
   B. The HttpOnly cookie attribute
   C. Nothing; editors may edit any article
   D. An ownership condition such as WHERE id = $1 AND author_id = $2 in the update

   **Answer: D.** Role checks decide what kind of action is allowed; ownership checks decide which objects. Without the author_id condition this is an IDOR vulnerability. Cookie attributes protect the session token, not object access.
