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.
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.
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:
// 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 passwordwhether 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.
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.
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.)
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.
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.
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:
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:
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):
- Seed
[email protected](roleadmin) and[email protected](roleviewer) with bcrypt hashes. - Add
POST /login(JSON email and password; 204 plus cookie on success, 401 otherwise),POST /logout,GET /me, andDELETE /v1/articles/{id}requiringarticles:delete. - Test with curl's cookie jar:
curl -c jar -d '{...}' localhost:8080/login, thencurl -b jar .... Ben should get 403 on DELETE; Ana should get 204. curl (and Chrome and Firefox) treatlocalhostas secure, so theSecurecookie works without TLS.
Show solution
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))
}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 -> 401Key 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/randtoken in anHttpOnly,Secure,SameSite=Laxcookie, 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.Why is SHA-256 a poor choice for storing passwords, even with a salt?
2.Why does Authenticate run bcrypt.CompareHashAndPassword against a dummy hash when the email doesn't exist?
3.What is the main advantage of server-side sessions over stateless JWT access tokens?
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.
- MiddlewareWrap a handler in another handler to add logging, auth or recovery to every request.
- Sessions vs tokensA random ID the server looks up, or a signed token the server verifies — and what each costs at logout.
- Password hashingStore a slow, salted hash — Argon2id or bcrypt — never the password and never a fast hash.
- XSS and Content Security PolicyUntrusted text rendered as HTML becomes the attacker’s script; escaping prevents it and CSP limits the damage.
Finished reading?
Mark it complete to fill in your roadmap.