Authentication for Go APIs With Keys Tokens and Sessions
Build Go API authentication with one Principal and three mechanisms: hashed API keys, signed bearer tokens and cookie sessions, with 401 versus 403 and CSRF.

Authentication answers one question: who is making this request? Authorization asks what they may do, and mixing the two is the first mistake in most APIs. This article is about the first. It builds three ways of proving identity in Go, an API key, a signed bearer token and a browser session, behind one small interface, and runs them against a server so each behavior is visible: the status codes, the challenge header, the cookie flags, and a timing leak that I introduced by accident and then measured.
Tested with Go 1.25.6 and golang.org/x/crypto v0.50.0, macOS on Apple Silicon (arm64)
It is the hub for the security cluster of the API series. API keys and bearer tokens with refresh flows go deeper into two of the three; OAuth 2 for your own clients covers the case where third parties act for users. The surrounding service is the one from the JSON API cornerstone, and the wider checklist is the API security checklist. Everything ran on Go 1.25.6.
Choose by who is calling
There is no best mechanism; there is a best mechanism for each kind of caller.
| Caller | Usual choice | Why |
|---|---|---|
| A server you know (billing, a cron job, a partner backend) | API key, or mutual TLS | Long-lived, easy to rotate, no user in the loop |
| A browser on your own site | Server-side session in an HttpOnly cookie | The browser stores and sends it, scripts cannot read it, logout is a delete |
| A mobile app or single-page app calling your API | Short-lived bearer token plus a refresh token | No cookies to manage, expiry limits a stolen token |
| A third-party app acting for your user | OAuth 2 access token | The user grants scopes without sharing a password |
A single API often has two or three of these at once, which is why the code below treats them as interchangeable plug-ins.
One Principal, many authenticators
The handlers should not know how a caller authenticated. They should see who it is and what they may do.
package main
import (
"context"
"net/http"
"slices"
)
// Principal is who a request acts as, however they proved it. Handlers see this and never the credential.
type Principal struct {
Subject string
Method string // "api-key", "session" or "bearer"
Scopes []string
}
func (p Principal) Has(scope string) bool { return slices.Contains(p.Scopes, scope) }
type ctxKey struct{}
func From(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(ctxKey{}).(Principal)
return p, ok
}
// Authenticator inspects a request. It returns (principal, true, nil) on success, (_, false, nil) when the request
// carries no credential of its kind, and an error when it carries one that is wrong.
type Authenticator interface {
Authenticate(r *http.Request) (Principal, bool, error)
}
func deny(w http.ResponseWriter, status int, challenge, msg string) {
if challenge != "" {
w.Header().Set("WWW-Authenticate", challenge)
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
w.Write([]byte(`{"title":"` + msg + `","status":` + map[int]string{401: "401", 403: "403"}[status] + "}\n"))
}
// Authenticate tries each authenticator in turn. A wrong credential is a 401 at once, so a bad API key
// is never quietly retried as something else.
func Authenticate(auths ...Authenticator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, a := range auths {
p, found, err := a.Authenticate(r)
if err != nil {
deny(w, 401, `Bearer realm="api"`, "invalid credentials")
return
}
if found {
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, p)))
return
}
}
deny(w, 401, `Bearer realm="api"`, "authentication required")
})
}
}
// Require turns a missing scope into 403: the caller is known, and not allowed.
func Require(scope string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p, ok := From(r.Context())
if !ok || !p.Has(scope) {
deny(w, 403, "", "forbidden")
return
}
next(w, r)
}
}Principal carries a subject, the method used and a list of scopes. Authenticator is the interface each mechanism implements, and its three-way result matters: found with a principal, not found because the request carries no credential of this kind, or an error because it carries one that is wrong. The Authenticate middleware tries each in order. If one reports a bad credential, the request ends with a 401 right there; it is not retried as the next mechanism, because a request with a wrong API key and a valid cookie is more likely an attack or a bug than a happy accident. If none finds a credential, the answer is a 401 saying authentication is required.
Both failures carry a WWW-Authenticate challenge, which HTTP requires on a 401. Require is the authorization half: a handler wrapped in Require("tasks:write", ...) returns a 403 when the principal lacks the scope. The difference between the two codes is not pedantry. A 401 tells the client to supply or fix credentials. A 403 tells it that the credentials are fine and retrying with them will never work.
API keys
package main
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"net/http"
"strings"
)
// KeyStore keeps a hash of each key, never the key. A database dump then contains nothing that can be used.
type KeyStore struct{ byID map[string]keyRecord }
type keyRecord struct {
hash [32]byte
subject string
scopes []string
}
func NewKeyStore() *KeyStore { return &KeyStore{byID: map[string]keyRecord{}} }
// Issue returns the only copy of the key the system will ever show: ghk_<id>_<secret>.
func (s *KeyStore) Issue(subject string, scopes ...string) string {
id, secret := token(6), token(24)
s.byID[id] = keyRecord{sha256.Sum256([]byte(secret)), subject, scopes}
return "ghk_" + id + "_" + secret
}
func token(n int) string {
b := make([]byte, n)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (s *KeyStore) Authenticate(r *http.Request) (Principal, bool, error) {
key := r.Header.Get("X-API-Key")
if key == "" {
return Principal{}, false, nil
}
parts := strings.SplitN(key, "_", 3)
if len(parts) != 3 || parts[0] != "ghk" {
return Principal{}, true, errBad
}
rec, ok := s.byID[parts[1]]
got := sha256.Sum256([]byte(parts[2]))
// Compare in constant time, and do the comparison even for an unknown id so the two failures look alike.
if !ok {
rec = keyRecord{}
}
if subtle.ConstantTimeCompare(got[:], rec.hash[:]) != 1 || !ok {
return Principal{}, true, errBad
}
return Principal{rec.subject, "api-key", rec.scopes}, true, nil
}An API key is a bearer secret: whoever holds it is the caller. Three decisions make it safer.
Store a hash, not the key. Issue returns the key once, in the form ghk_<id>_<secret>, and stores only the SHA-256 of the secret under the id. A database leak then reveals nothing that can be used. A fast hash is right here, unlike for passwords, because the secret is 24 random bytes and cannot be guessed by trying candidates. A test asserts that the store never contains the secret.
Put an id in the key. The id lets the lookup be a map access instead of a comparison against every record, and lets you show "key abc123 last used" in a dashboard without showing the secret. A recognisable prefix (ghk_) makes leaked keys easy to find with secret scanners.
Compare in constant time. subtle.ConstantTimeCompare takes the same time whether the first byte differs or the last. For an unknown id the code still runs the comparison against a zero record, so the two failure paths look alike. The full treatment, with rotation and scopes, is in API keys in Go.
Signed bearer tokens
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
)
var errBad = errors.New("bad credential")
// Signer issues and checks short-lived signed tokens: base64url(claims) + "." + base64url(HMAC-SHA256).
// It has the shape of a JWT without the header; for real JWTs use a library (see the refresh token article).
type Signer struct {
key []byte
now func() time.Time
}
type claims struct {
Sub string `json:"sub"`
Scopes []string `json:"scp"`
Exp int64 `json:"exp"`
}
func (s *Signer) mac(payload string) string {
m := hmac.New(sha256.New, s.key)
m.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}
func (s *Signer) Issue(sub string, ttl time.Duration, scopes ...string) string {
b, _ := json.Marshal(claims{sub, scopes, s.now().Add(ttl).Unix()})
p := base64.RawURLEncoding.EncodeToString(b)
return p + "." + s.mac(p)
}
func (s *Signer) Authenticate(r *http.Request) (Principal, bool, error) {
h := r.Header.Get("Authorization")
tok, ok := strings.CutPrefix(h, "Bearer ")
if !ok {
return Principal{}, false, nil
}
p, sig, ok := strings.Cut(tok, ".")
if !ok || !hmac.Equal([]byte(sig), []byte(s.mac(p))) {
return Principal{}, true, errBad
}
raw, err := base64.RawURLEncoding.DecodeString(p)
if err != nil {
return Principal{}, true, errBad
}
var c claims
if json.Unmarshal(raw, &c) != nil || s.now().Unix() >= c.Exp {
return Principal{}, true, errBad
}
return Principal{c.Sub, "bearer", c.Scopes}, true, nil
}A bearer token proves identity without a lookup: the claims (subject, scopes, expiry) are signed, and the server verifies the signature and the expiry. The Signer here produces base64url(claims).base64url(HMAC-SHA256). It has the shape of a JWT without the header, and it is a teaching format; use a maintained JWT library for real, with a pinned algorithm, as the refresh token article does.
Four properties to notice. The signature is checked with hmac.Equal, a constant-time compare. The clock is injected (now), so tests control expiry. Expiry is mandatory: a token without an exp would be valid forever, so the check is not optional. And revocation is the price: a stateless token cannot be recalled before it expires, which is why access tokens should be short (minutes) and paired with a refresh token you can revoke.
Browser sessions
package main
import (
"net/http"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// Sessions keeps state on the server and gives the browser an opaque id. Logging out is deleting the entry.
type Sessions struct {
mu sync.Mutex
byID map[string]session
users map[string][]byte // username -> bcrypt hash
scopes map[string][]string
now func() time.Time
cost int
dummy []byte // a valid hash nobody can match, compared against when the user does not exist
}
type session struct {
user string
expires time.Time
}
func NewSessions(now func() time.Time, cost int) *Sessions {
dummy, _ := bcrypt.GenerateFromPassword([]byte(token(16)), cost)
return &Sessions{byID: map[string]session{}, users: map[string][]byte{}, scopes: map[string][]string{}, now: now, cost: cost, dummy: dummy}
}
func (s *Sessions) AddUser(name, password string, scopes ...string) {
h, _ := bcrypt.GenerateFromPassword([]byte(password), s.cost)
s.users[name], s.scopes[name] = h, scopes
}
func (s *Sessions) Login(w http.ResponseWriter, r *http.Request) {
user, pass, _ := r.BasicAuth()
hash, ok := s.users[user]
if !ok {
hash = s.dummy // spend the same time as a real check, so response time does not reveal which names exist
}
if bcrypt.CompareHashAndPassword(hash, []byte(pass)) != nil || !ok {
deny(w, 401, "", "invalid username or password")
return
}
id := token(32)
s.mu.Lock()
s.byID[id] = session{user, s.now().Add(2 * time.Hour)}
s.mu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "sid", Value: id, Path: "/", HttpOnly: true, Secure: true,
SameSite: http.SameSiteLaxMode, MaxAge: int((2 * time.Hour).Seconds()),
})
w.WriteHeader(204)
}
func (s *Sessions) Logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("sid"); err == nil {
s.mu.Lock()
delete(s.byID, c.Value)
s.mu.Unlock()
}
http.SetCookie(w, &http.Cookie{Name: "sid", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true})
w.WriteHeader(204)
}
func (s *Sessions) Authenticate(r *http.Request) (Principal, bool, error) {
c, err := r.Cookie("sid")
if err != nil {
return Principal{}, false, nil
}
s.mu.Lock()
sess, ok := s.byID[c.Value]
s.mu.Unlock()
if !ok || !s.now().Before(sess.expires) {
return Principal{}, true, errBad
}
return Principal{sess.user, "session", s.scopes[sess.user]}, true, nil
}For a browser, the simplest secure design is the oldest: the server remembers the session, and the browser holds an opaque id.
Login. The handler reads the username and password, checks them with bcrypt, creates a random 32-byte id, stores it with an expiry and sets a cookie. The cookie flags are the security: HttpOnly hides it from JavaScript, so an XSS bug cannot read it; Secure keeps it off plain HTTP; SameSite=Lax stops the browser sending it on most cross-site requests. A test pins all three.
Logout. Logout deletes the server-side entry. A cookie copied before logout becomes worthless, which a signed stateless token cannot offer. The test replays the old cookie after logout and requires a 401.
Unknown users. If the username does not exist, the code still runs a bcrypt comparison, against a valid dummy hash created at start-up. This is the part I got wrong first. My initial version used a constant string that was not a valid bcrypt hash, as a stand-in; CompareHashAndPassword rejects a malformed hash immediately. The timing test I then wrote failed: a wrong password for a real user took a median of 4.4 ms and an unknown user 1.1 ms, so anyone could find which usernames exist by measuring. Comparing against a real dummy hash fixed it. Under the race detector, the fixed version measured 44.9 ms for a real user and 44.3 ms for an unknown one. The test stays, because this kind of regression is invisible otherwise.
CSRF. Cookies are sent automatically, which is the whole problem with them: a page on another site can cause the browser to make a request to yours, with the cookie attached. SameSite=Lax stops most of it. The Go 1.25 standard library adds a second layer, http.NewCrossOriginProtection, which rejects unsafe requests (POST, PUT, DELETE) that browsers mark as cross-site, using the Sec-Fetch-Site and Origin headers, with no tokens to manage. The server below wraps the whole mux in it.
Running it
package main
import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func routes(keys *KeyStore, signer *Signer, sessions *Sessions) http.Handler {
auth := Authenticate(keys, signer, sessions)
api := http.NewServeMux()
api.HandleFunc("GET /whoami", func(w http.ResponseWriter, r *http.Request) {
p, _ := From(r.Context())
fmt.Fprintf(w, "%s via %s scopes=%v\n", p.Subject, p.Method, p.Scopes)
})
api.HandleFunc("POST /tasks", Require("tasks:write", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(201)
fmt.Fprintln(w, "task created")
}))
root := http.NewServeMux()
root.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
root.HandleFunc("POST /login", sessions.Login)
root.HandleFunc("POST /logout", sessions.Logout)
root.Handle("/", auth(api))
// Cookies are sent by the browser on cross-site requests; refuse unsafe ones that did not start here.
return http.NewCrossOriginProtection().Handler(root)
}
func main() {
clock := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
now := func() time.Time { return clock }
keys, signer, sessions := NewKeyStore(), &Signer{key: []byte("a-32-byte-secret-for-hmac-signing"), now: now}, NewSessions(now, bcrypt.MinCost)
apiKey := keys.Issue("billing-service", "tasks:write")
readKey := keys.Issue("dashboard", "tasks:read")
sessions.AddUser("ada", "correct horse", "tasks:write")
srv := httptest.NewTLSServer(routes(keys, signer, sessions))
defer srv.Close()
jar, _ := cookiejar.New(nil)
browser := srv.Client()
browser.Jar = jar
plain := srv.Client()
do := func(c *http.Client, label, method, path string, hdr ...string) {
req, _ := http.NewRequest(method, srv.URL+path, nil)
for i := 0; i+1 < len(hdr); i += 2 {
if hdr[i] == "basic" {
u, p, _ := strings.Cut(hdr[i+1], ":")
req.SetBasicAuth(u, p)
continue
}
req.Header.Set(hdr[i], hdr[i+1])
}
res, err := c.Do(req)
if err != nil {
panic(err)
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
out := strings.TrimSpace(string(b))
if ch := res.Header.Get("WWW-Authenticate"); ch != "" {
out += " [WWW-Authenticate: " + ch + "]"
}
fmt.Printf("%-34s %d %s\n", label, res.StatusCode, out)
}
fmt.Println("== no credentials, and the one public route")
do(plain, "GET /healthz", "GET", "/healthz")
do(plain, "GET /whoami", "GET", "/whoami")
fmt.Println("\n== API key")
do(plain, "valid key", "GET", "/whoami", "X-API-Key", apiKey)
do(plain, "key with the wrong secret", "GET", "/whoami", "X-API-Key", apiKey[:len(apiKey)-3]+"abc")
do(plain, "unknown key id", "GET", "/whoami", "X-API-Key", "ghk_nope_whatever")
do(plain, "POST /tasks with write scope", "POST", "/tasks", "X-API-Key", apiKey)
do(plain, "POST /tasks with read-only key", "POST", "/tasks", "X-API-Key", readKey)
fmt.Println("\n== bearer token")
tok := signer.Issue("ada", 15*time.Minute, "tasks:write")
do(plain, "valid token", "GET", "/whoami", "Authorization", "Bearer "+tok)
do(plain, "tampered payload", "GET", "/whoami", "Authorization", "Bearer "+"x"+tok[1:])
expired := signer.Issue("ada", -time.Minute, "tasks:write")
do(plain, "expired token", "GET", "/whoami", "Authorization", "Bearer "+expired)
fmt.Println("\n== browser session")
do(browser, "login with a wrong password", "POST", "/login", "basic", "ada:wrong")
do(browser, "login", "POST", "/login", "basic", "ada:correct horse")
do(browser, "GET /whoami with the cookie", "GET", "/whoami")
do(browser, "POST /tasks, same origin", "POST", "/tasks")
do(browser, "POST /tasks from another site", "POST", "/tasks", "Origin", "https://evil.example", "Sec-Fetch-Site", "cross-site")
do(browser, "logout", "POST", "/logout")
do(browser, "GET /whoami after logout", "GET", "/whoami")
}== no credentials, and the one public route
GET /healthz 200 ok
GET /whoami 401 {"title":"authentication required","status":401} [WWW-Authenticate: Bearer realm="api"]
== API key
valid key 200 billing-service via api-key scopes=[tasks:write]
key with the wrong secret 401 {"title":"invalid credentials","status":401} [WWW-Authenticate: Bearer realm="api"]
unknown key id 401 {"title":"invalid credentials","status":401} [WWW-Authenticate: Bearer realm="api"]
POST /tasks with write scope 201 task created
POST /tasks with read-only key 403 {"title":"forbidden","status":403}
== bearer token
valid token 200 ada via bearer scopes=[tasks:write]
tampered payload 401 {"title":"invalid credentials","status":401} [WWW-Authenticate: Bearer realm="api"]
expired token 401 {"title":"invalid credentials","status":401} [WWW-Authenticate: Bearer realm="api"]
== browser session
login with a wrong password 401 {"title":"invalid username or password","status":401}
login 204
GET /whoami with the cookie 200 ada via session scopes=[tasks:write]
POST /tasks, same origin 201 task created
POST /tasks from another site 403 cross-origin request detected from Sec-Fetch-Site header
logout 204
GET /whoami after logout 401 {"title":"authentication required","status":401} [WWW-Authenticate: Bearer realm="api"]The program issues a write key and a read-only key, creates a user, and walks through the cases.
- No credentials.
/healthz(outside the middleware) answers 200./whoamianswers 401 withauthentication requiredand the challenge header. - API key. A valid key authenticates as
billing-servicewith thetasks:writescope. A key with a changed secret and a key with an unknown id both get the same 401invalid credentials; a client cannot tell which part was wrong. The write key canPOST /tasks(201). The read-only key gets a 403: known, not allowed. - Bearer token. A fresh token works. A token whose payload was changed fails the signature check (401), and an expired token is refused (401).
- Session. A wrong password gets a 401 and no cookie. A correct login returns 204 and sets the cookie, which the client's jar then sends.
/whoamishowsada via session. A same-originPOST /taskssucceeds. The same request withOrigin: https://evil.exampleandSec-Fetch-Site: cross-siteis rejected with a 403 by the cross-origin protection before authentication runs. After logout the cookie is gone and/whoamiis a 401 again.
Tests
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
)
func fixture() (http.Handler, *KeyStore, *Signer, *Sessions) {
now := func() time.Time { return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) }
keys, signer, sessions := NewKeyStore(), &Signer{key: []byte("a-32-byte-secret-for-hmac-signing"), now: now}, NewSessions(now, 6)
sessions.AddUser("ada", "correct horse", "tasks:write")
return routes(keys, signer, sessions), keys, signer, sessions
}
func login(h http.Handler, user, pass string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/login", nil)
req.SetBasicAuth(user, pass)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestUnknownUserAndWrongPasswordAreIndistinguishable(t *testing.T) {
h, _, _, _ := fixture()
a, b := login(h, "ada", "wrong"), login(h, "nobody", "wrong")
if a.Code != 401 || b.Code != 401 || a.Body.String() != b.Body.String() {
t.Fatalf("%d %q vs %d %q", a.Code, a.Body, b.Code, b.Body)
}
}
func TestUnknownUserCostsAboutAsMuchAsAWrongPassword(t *testing.T) {
h, _, _, _ := fixture()
median := func(user string) time.Duration {
var d []time.Duration
for i := 0; i < 15; i++ {
t0 := time.Now()
login(h, user, "wrong")
d = append(d, time.Since(t0))
}
sort.Slice(d, func(i, j int) bool { return d[i] < d[j] })
return d[len(d)/2]
}
known, unknown := median("ada"), median("nobody")
t.Logf("median wrong password for a real user: %v, for an unknown user: %v", known, unknown)
if unknown < known/2 {
t.Fatalf("an unknown user answers in %v against %v: the response time reveals which names exist", unknown, known)
}
}
func TestLoggingOutKillsAStolenCookie(t *testing.T) {
h, _, _, _ := fixture()
ck := login(h, "ada", "correct horse").Result().Cookies()[0]
call := func(method, path string) int {
req := httptest.NewRequest(method, path, nil)
req.AddCookie(ck)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
if code := call("GET", "/whoami"); code != 200 {
t.Fatalf("before logout: %d", code)
}
call("POST", "/logout")
if code := call("GET", "/whoami"); code != 401 {
t.Fatalf("a replayed cookie still works after logout: %d", code)
}
}
func TestOnlyAHashOfTheKeyIsStored(t *testing.T) {
_, keys, _, _ := fixture()
key := keys.Issue("svc", "tasks:read")
secret := key[strings.LastIndex(key, "_")+1:]
if strings.Contains(fmt.Sprintf("%v", keys.byID), secret) {
t.Fatal("the store holds the secret itself")
}
}
func TestSessionCookieFlags(t *testing.T) {
h, _, _, _ := fixture()
c := login(h, "ada", "correct horse").Result().Cookies()[0]
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.MaxAge <= 0 {
t.Fatalf("%+v", c)
}
}=== RUN TestUnknownUserAndWrongPasswordAreIndistinguishable
--- PASS: TestUnknownUserAndWrongPasswordAreIndistinguishable (0.19s)
=== RUN TestUnknownUserCostsAboutAsMuchAsAWrongPassword
auth_test.go:49: median wrong password for a real user: 44.925ms, for an unknown user: 44.342958ms
--- PASS: TestUnknownUserCostsAboutAsMuchAsAWrongPassword (1.44s)
=== RUN TestLoggingOutKillsAStolenCookie
--- PASS: TestLoggingOutKillsAStolenCookie (0.13s)
=== RUN TestOnlyAHashOfTheKeyIsStored
--- PASS: TestOnlyAHashOfTheKeyIsStored (0.09s)
=== RUN TestSessionCookieFlags
--- PASS: TestSessionCookieFlags (0.13s)
PASS
ok example.test/lab 3.285sThe tests cover the properties that matter and are easy to break: an unknown user and a wrong password give identical responses and take comparable time; a logged-out cookie is dead; the key store holds no secret; the cookie has its flags. A new authenticator should come with its own versions of the first three.
What the hashing costs
Passwords need a deliberately slow hash. Bcrypt's cost parameter sets how slow, and the right value is the largest your login latency budget allows.
package main
import (
"testing"
"golang.org/x/crypto/bcrypt"
)
func benchCost(b *testing.B, cost int) {
h, _ := bcrypt.GenerateFromPassword([]byte("correct horse"), cost)
b.ResetTimer()
for b.Loop() {
bcrypt.CompareHashAndPassword(h, []byte("correct horse"))
}
}
func BenchmarkBcryptCost4(b *testing.B) { benchCost(b, 4) }
func BenchmarkBcryptCost10(b *testing.B) { benchCost(b, 10) }
func BenchmarkBcryptCost12(b *testing.B) { benchCost(b, 12) }goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkBcryptCost4-10 996 1065106 ns/op 5205 B/op 11 allocs/op
BenchmarkBcryptCost10-10 18 63630109 ns/op 5204 B/op 11 allocs/op
BenchmarkBcryptCost12-10 4 256839667 ns/op 5204 B/op 11 allocs/op
PASS
ok example.test/lab 3.822sOn an Apple M1 Max one verification took 1.07 ms at cost 4, 63.6 ms at cost 10 and 257 ms at cost 12, a doubling for every step. Cost 4 is the minimum and only for tests; cost 10 is the library default; 12 or more suits a login that can afford a quarter of a second. Each increment doubles an attacker's work as well as yours, and the number belongs in configuration so you can raise it as hardware improves. Because a login costs that much CPU, rate-limit it (rate limiting per key), or a flood of bad passwords becomes a denial of service. Argon2id is the other recommended choice; the principle is the same.
Running several mechanisms together
Real services mix these, and a few rules keep the mix safe.
Order the authenticators from cheapest and most specific to most general. The code tries the API key header first, then the bearer token, then the cookie. Each looks for its own marker and steps aside when it is absent, which is why the three-way result exists. Do not guess a mechanism from the shape of a value.
Authenticate before you rate-limit. A limit keyed by principal (not by IP) is fairer and harder to dodge, and it needs the principal to exist. Put a small, coarse limit by address in front of the whole thing to protect the login route and the hash computation, then the per-principal limit after authentication.
Keep scopes few and named for what they allow. tasks:read and tasks:write are easy to reason about; thirty fine-grained scopes become a permissions system that nobody can review. Give a key the narrowest scope that does its job, the read-only key above is the model, and check the scope at the handler or route, where the action is.
Rotate without downtime. For keys, allow two active keys per client for an overlap period. For signing secrets, accept tokens signed with the previous key until their expiry has passed, and sign with the new one. Neither is possible if the secret is baked into the code, so load them from the environment or a secrets manager.
Prefer a standard when you cross a trust boundary. Within one team a signed token and a session cookie are fine. Between organizations, use OAuth 2 or mutual TLS so that the other side can use libraries, tooling and audits that already exist.
Common mistakes
Returning 403 for missing credentials, or 401 for a missing scope. The codes mean different things to clients and to monitoring.
Different messages for "no such user" and "wrong password". Together with the timing difference, this is user enumeration.
Putting credentials in URLs. Query strings end up in logs, history and Referer headers. Keys and tokens belong in headers.
Logging the credential. Redact Authorization, Cookie and X-API-Key in access logs (redacting logs).
Long-lived bearer tokens with no revocation. Keep them to minutes, or keep a server-side check.
Treating authentication as authorization. An authenticated caller can still be asking for someone else's object; check ownership on every resource (broken access control).
Rolling your own crypto. The signed token above is a teaching format. Use maintained libraries for JWT, OAuth and password hashing, and read the crypto/subtle and net/http documentation for the parts you do write.


