Skip to content
gohacks.dev
APIs

OAuth 2 Server Basics in Go for Your Own API Clients

Build a small OAuth 2 server in Go: client credentials, authorization code with PKCE, exact redirect matching, single-use codes and a scope-checking API.

21 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

OAuth 2 has a reputation for being complicated, and most of the reputation comes from the number of options, not from the core idea. A client wants to call an API. It proves who it is or gets a user's permission, receives a token, and presents the token to the API. The authorization server issues tokens, and the resource server (your API) checks them. If your API has clients you control, such as a mobile app, a partner integration or internal services, you can run the authorization server yourself, and a small one needs only two of the grants.

Tested with Go 1.25.6, golang-jwt v5.3.1 and golang.org/x/oauth2 v0.34.0, macOS on Apple Silicon (arm64)

This article builds that server in Go and then drives it with the standard client library, golang.org/x/oauth2. It implements client credentials (a machine acting as itself), the authorization code grant with PKCE (a user acting through an app that cannot keep a secret), the redirect checks that decide whether the whole thing is safe, and the resource server that verifies the result. It builds on the two-token design and authentication for Go APIs, and the token verification is the same pinned JWT parsing. One honest note first: for production, use a maintained authorization server or library, and treat this as the way to understand what it is doing. Everything ran on Go 1.25.6.

What OAuth 2 is solving

Three problems, in the order they appear.

Delegation. A user should be able to let an app act on their behalf without giving it their password. The app gets a token with limited scopes, and the user can revoke it.

Client identity. The API should know which application is calling, not just which user. A client is registered with an id, optional secret, allowed redirect URIs, allowed grants and allowed scopes.

Short-lived credentials. Tokens expire, so a leaked one has a limited life.

The roles are: the resource owner (the user), the client (the app), the authorization server (issues tokens) and the resource server (your API). In a small system one binary can play the last two.

Choosing the grants

OAuth 2 defines several grants. Two are enough for first-party clients, and two should not be used.

GrantUse forStatus
Client credentialsA server calling your API as itself, no userUse
Authorization code with PKCEA user signing in through an app: browser, mobile, desktopUse, and PKCE always
Refresh tokenRenewing access without the userUse, with rotation (refresh tokens)
ImplicitOld single-page appsDo not use; removed in OAuth 2.1
Resource owner passwordApps that collect the user's passwordDo not use; removed in OAuth 2.1

The authorization server

server.goGo
package main

import (
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/json"
	"net/http"
	"net/url"
	"slices"
	"strings"
	"sync"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// Client is a registered application. Confidential clients can keep a secret; public clients (apps, SPAs) cannot.
type Client struct {
	ID           string
	SecretHash   [32]byte // zero for public clients
	RedirectURIs []string
	Grants       []string
	Scopes       []string
}

func (c Client) Public() bool { return c.SecretHash == [32]byte{} }

type authCode struct {
	clientID, redirectURI, challenge, user string
	scopes                                 []string
	expires                                time.Time
}

type Server struct {
	Issuer  string
	Key     []byte
	Now     func() time.Time
	clients map[string]Client
	mu      sync.Mutex
	codes   map[string]authCode
	// Demo stand-in for a login page: a real server shows a form and a consent screen here.
	LoginUser func(r *http.Request) string
}

func NewServer(issuer string, key []byte, now func() time.Time, clients ...Client) *Server {
	s := &Server{Issuer: issuer, Key: key, Now: now, clients: map[string]Client{}, codes: map[string]authCode{}}
	for _, c := range clients {
		s.clients[c.ID] = c
	}
	return s
}

func (s *Server) Handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /authorize", s.authorize)
	mux.HandleFunc("POST /token", s.token)
	mux.HandleFunc("GET /.well-known/oauth-authorization-server", s.metadata)
	return mux
}

func (s *Server) metadata(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, 200, map[string]any{
		"issuer": s.Issuer, "authorization_endpoint": s.Issuer + "/authorize", "token_endpoint": s.Issuer + "/token",
		"grant_types_supported":                 []string{"authorization_code", "client_credentials"},
		"code_challenge_methods_supported":      []string{"S256"},
		"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "none"},
		"response_types_supported":              []string{"code"},
	})
}

func (s *Server) authorize(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	c, ok := s.clients[q.Get("client_id")]
	// Until the client and the redirect URI are proven, never redirect: an error page, not a redirect to a stranger.
	if !ok || !slices.Contains(c.RedirectURIs, q.Get("redirect_uri")) {
		http.Error(w, "unknown client or redirect_uri", http.StatusBadRequest)
		return
	}
	back := func(params url.Values) {
		u, _ := url.Parse(q.Get("redirect_uri"))
		qq := u.Query()
		for k, v := range params {
			qq[k] = v
		}
		if st := q.Get("state"); st != "" {
			qq.Set("state", st)
		}
		u.RawQuery = qq.Encode()
		http.Redirect(w, r, u.String(), http.StatusFound)
	}
	if q.Get("response_type") != "code" {
		back(url.Values{"error": {"unsupported_response_type"}})
		return
	}
	if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
		back(url.Values{"error": {"invalid_request"}, "error_description": {"PKCE with S256 is required"}})
		return
	}
	scopes := strings.Fields(q.Get("scope"))
	for _, sc := range scopes {
		if !slices.Contains(c.Scopes, sc) {
			back(url.Values{"error": {"invalid_scope"}})
			return
		}
	}
	user := s.LoginUser(r)
	if user == "" {
		back(url.Values{"error": {"access_denied"}})
		return
	}
	code := randomString(32)
	s.mu.Lock()
	s.codes[code] = authCode{c.ID, q.Get("redirect_uri"), q.Get("code_challenge"), user, scopes, s.Now().Add(time.Minute)}
	s.mu.Unlock()
	back(url.Values{"code": {code}})
}

func (s *Server) token(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Pragma", "no-cache")
	if err := r.ParseForm(); err != nil {
		oauthError(w, 400, "invalid_request", "")
		return
	}
	c, ok := s.authenticateClient(r)
	if !ok {
		w.Header().Set("WWW-Authenticate", `Basic realm="token"`)
		oauthError(w, 401, "invalid_client", "")
		return
	}
	switch r.PostForm.Get("grant_type") {
	case "client_credentials":
		if c.Public() || !slices.Contains(c.Grants, "client_credentials") {
			oauthError(w, 400, "unauthorized_client", "")
			return
		}
		scopes := strings.Fields(r.PostForm.Get("scope"))
		if len(scopes) == 0 {
			scopes = c.Scopes
		}
		for _, sc := range scopes {
			if !slices.Contains(c.Scopes, sc) {
				oauthError(w, 400, "invalid_scope", "")
				return
			}
		}
		s.respond(w, c.ID, scopes) // the subject of a machine token is the client itself
	case "authorization_code":
		s.mu.Lock()
		ac, found := s.codes[r.PostForm.Get("code")]
		delete(s.codes, r.PostForm.Get("code")) // single use, whatever happens next
		s.mu.Unlock()
		if !found || ac.clientID != c.ID || ac.redirectURI != r.PostForm.Get("redirect_uri") || !s.Now().Before(ac.expires) {
			oauthError(w, 400, "invalid_grant", "")
			return
		}
		sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier")))
		if subtle.ConstantTimeCompare([]byte(base64.RawURLEncoding.EncodeToString(sum[:])), []byte(ac.challenge)) != 1 {
			oauthError(w, 400, "invalid_grant", "PKCE verification failed")
			return
		}
		s.respond(w, ac.user, ac.scopes)
	default:
		oauthError(w, 400, "unsupported_grant_type", "")
	}
}

// authenticateClient accepts HTTP Basic credentials for confidential clients and just a client_id for public ones.
func (s *Server) authenticateClient(r *http.Request) (Client, bool) {
	id, secret, hasBasic := r.BasicAuth()
	if !hasBasic {
		id = r.PostForm.Get("client_id")
	}
	c, ok := s.clients[id]
	if !ok {
		return Client{}, false
	}
	if c.Public() {
		return c, !hasBasic
	}
	got := sha256.Sum256([]byte(secret))
	return c, hasBasic && subtle.ConstantTimeCompare(got[:], c.SecretHash[:]) == 1
}

func (s *Server) respond(w http.ResponseWriter, subject string, scopes []string) {
	now := s.Now()
	tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"iss": s.Issuer, "aud": "tasks-api", "sub": subject, "scope": strings.Join(scopes, " "),
		"iat": now.Unix(), "exp": now.Add(15 * time.Minute).Unix(),
	}).SignedString(s.Key)
	writeJSON(w, 200, map[string]any{"access_token": tok, "token_type": "Bearer", "expires_in": 900, "scope": strings.Join(scopes, " ")})
}

func oauthError(w http.ResponseWriter, status int, code, desc string) {
	body := map[string]string{"error": code}
	if desc != "" {
		body["error_description"] = desc
	}
	writeJSON(w, status, body)
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func randomString(n int) string {
	b := make([]byte, n)
	rand.Read(b)
	return base64.RawURLEncoding.EncodeToString(b)
}

The server has three endpoints.

/.well-known/oauth-authorization-server is the metadata document defined by RFC 8414. It tells clients where the authorize and token endpoints are, which grants and challenge methods are supported, and how clients authenticate. Libraries use it for discovery, and it documents your choices in one place.

/authorize is where the user's browser arrives. The handler does its checks in a particular order, and the order matters. First it validates the client_id and the redirect_uri: if either is unknown, it responds with an error page and does not redirect. Redirecting an error to an unverified URI turns the server into an open redirector. Only after the client and URI are proven does it use the redirect for everything else: unsupported response type, missing PKCE, a scope the client may not request, or a user who declined. Each of those goes back to the client as error=... with the state preserved.

For a valid request the handler creates a random authorization code, stores it with the client, the redirect URI, the PKCE challenge, the user and the scopes, with a one-minute expiry, and redirects back with code and state. The LoginUser function stands in for the login and consent screen. A real server shows a page here, authenticates the user (sessions) and asks them to approve the scopes. The demo reads a header so the flow is runnable.

/token exchanges a code, or a client's own credentials, for an access token. It sets Cache-Control: no-store first, because a token response must never be cached. It authenticates the client with HTTP Basic for confidential clients (client_secret_basic) or just a client_id for public ones, and then dispatches on grant_type.

For client_credentials it requires a confidential client with that grant and checks the requested scopes are a subset of the client's. The token's subject is the client itself.

For authorization_code it looks up the code and deletes it immediately, whatever happens next, so a code can be tried once. It checks that the client matches, that the redirect URI is the same string used at /authorize, that the code has not expired, and then the PKCE check, below. Every failure is the same invalid_grant, with no hint about which check failed beyond a short description for PKCE.

Errors use the format defined in RFC 6749: a JSON object with error and optional error_description. A failed client authentication is a 401 with a WWW-Authenticate challenge; the rest are 400.

PKCE, the part that protects public clients

A mobile app cannot keep a secret: anything compiled into the app can be extracted. Without a secret, the authorization code is the only thing standing between an attacker who intercepts the redirect and a token. PKCE (RFC 7636) closes that hole.

Before starting, the client generates a random verifier and sends its SHA-256 hash, the challenge, with the authorization request. The server stores the challenge with the code. When the client redeems the code it sends the verifier, and the server hashes it and compares. An attacker who intercepted the code does not have the verifier, which never left the app, and cannot redeem it. The server above requires S256 and refuses requests without a challenge; it does not support the weaker plain method.

PKCE is useful for confidential clients too, and recent guidance says to use it everywhere.

The resource server

api.goGo
package main

import (
	"fmt"
	"net/http"
	"slices"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// ResourceServer is the API the tokens are for. It only needs the signing key, the issuer and its own audience.
func ResourceServer(key []byte, issuer string, now func() time.Time) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /v1/tasks", scoped(key, issuer, now, "tasks:read", func(w http.ResponseWriter, r *http.Request, sub string) {
		fmt.Fprintf(w, "tasks visible to %s\n", sub)
	}))
	mux.HandleFunc("POST /v1/tasks", scoped(key, issuer, now, "tasks:write", func(w http.ResponseWriter, r *http.Request, sub string) {
		w.WriteHeader(201)
		fmt.Fprintf(w, "task created for %s\n", sub)
	}))
	return mux
}

func scoped(key []byte, issuer string, now func() time.Time, need string, h func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
		if !ok {
			w.Header().Set("WWW-Authenticate", "Bearer")
			http.Error(w, "missing token", 401)
			return
		}
		claims := jwt.MapClaims{}
		_, err := jwt.ParseWithClaims(raw, claims, func(*jwt.Token) (any, error) { return key, nil },
			jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer(issuer), jwt.WithAudience("tasks-api"),
			jwt.WithExpirationRequired(), jwt.WithTimeFunc(now))
		if err != nil {
			w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
			http.Error(w, "invalid token", 401)
			return
		}
		scope, _ := claims["scope"].(string)
		if !slices.Contains(strings.Fields(scope), need) {
			w.Header().Set("WWW-Authenticate", `Bearer error="insufficient_scope", scope="`+need+`"`)
			http.Error(w, "insufficient scope", 403)
			return
		}
		sub, _ := claims["sub"].(string)
		h(w, r, sub)
	}
}

The API verifies tokens exactly as in the refresh token article: HS256 only, the right issuer, the audience tasks-api, a required expiry. A route declares the scope it needs, and the wrapper returns 401 with error="invalid_token" for a bad token and 403 with error="insufficient_scope" and the missing scope in the challenge for a good token without the scope. Those challenge attributes are defined in RFC 6750 and let a client library decide whether to ask for more permission.

If the resource server and the authorization server are separate services, switch to an asymmetric signature so the API only holds a public key, and publish the keys as a JWKS document.

Driving it with the standard client

main.goGo
package main

import (
	"context"
	"crypto/sha256"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"time"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/clientcredentials"
)

var key = []byte("a-32-byte-secret-for-hmac-signing")

// world starts an authorization server and a resource server on one test host.
func world(now func() time.Time) (*httptest.Server, *Server) {
	mux := http.NewServeMux()
	srv := httptest.NewServer(mux)
	issuer := srv.URL + "/oauth"
	as := NewServer(issuer, key, now,
		Client{ID: "billing-job", SecretHash: sha256.Sum256([]byte("s3cret")), Grants: []string{"client_credentials"}, Scopes: []string{"tasks:read", "tasks:write"}},
		Client{ID: "mobile-app", RedirectURIs: []string{"com.example.tasks:/callback"}, Grants: []string{"authorization_code"}, Scopes: []string{"tasks:read"}},
	)
	as.LoginUser = func(r *http.Request) string { return r.Header.Get("X-Demo-User") }
	mux.Handle("/oauth/", http.StripPrefix("/oauth", as.Handler()))
	mux.Handle("/", ResourceServer(key, issuer, now))
	return srv, as
}

func get(c *http.Client, url string) string {
	res, err := c.Get(url)
	if err != nil {
		return "error: " + err.Error()
	}
	defer res.Body.Close()
	b, _ := io.ReadAll(res.Body)
	return fmt.Sprintf("%d %s", res.StatusCode, strings.TrimSpace(string(b)))
}

func main() {
	now := func() time.Time { return time.Now() }
	srv, as := world(now)
	defer srv.Close()
	ctx := context.Background()
	_ = as

	fmt.Println("== client credentials: a machine acting as itself")
	cc := clientcredentials.Config{ClientID: "billing-job", ClientSecret: "s3cret", TokenURL: srv.URL + "/oauth/token", Scopes: []string{"tasks:read"}}
	machine := cc.Client(ctx)
	fmt.Println("GET  /v1/tasks (scope tasks:read):  ", get(machine, srv.URL+"/v1/tasks"))
	res, _ := machine.Post(srv.URL+"/v1/tasks", "application/json", nil)
	fmt.Println("POST /v1/tasks (needs tasks:write): ", res.StatusCode, res.Header.Get("WWW-Authenticate"))
	bad := clientcredentials.Config{ClientID: "billing-job", ClientSecret: "wrong", TokenURL: srv.URL + "/oauth/token"}
	_, err := bad.Token(ctx)
	fmt.Println("wrong client secret:               ", err)

	fmt.Println("\n== authorization code with PKCE: a user, through an app that cannot keep a secret")
	conf := oauth2.Config{
		ClientID: "mobile-app", RedirectURL: "com.example.tasks:/callback", Scopes: []string{"tasks:read"},
		Endpoint: oauth2.Endpoint{AuthURL: srv.URL + "/oauth/authorize", TokenURL: srv.URL + "/oauth/token"},
	}
	verifier := oauth2.GenerateVerifier()
	authURL := conf.AuthCodeURL("xyz-state", oauth2.S256ChallengeOption(verifier))
	fmt.Println("authorize URL carries:", strings.Join(pick(authURL, "response_type", "client_id", "code_challenge_method", "state"), " "))

	noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
	authorize := func(rawURL, user string) url.Values {
		req, _ := http.NewRequest("GET", rawURL, nil)
		if user != "" {
			req.Header.Set("X-Demo-User", user)
		}
		res, err := noRedirect.Do(req)
		if err != nil {
			panic(err)
		}
		if res.StatusCode != 302 {
			return url.Values{"status": {fmt.Sprint(res.StatusCode)}}
		}
		loc, _ := url.Parse(res.Header.Get("Location"))
		return loc.Query()
	}
	cb := authorize(authURL, "ada")
	fmt.Println("redirected back with state =", cb.Get("state"), "and a code of", len(cb.Get("code")), "characters")

	tok, err := conf.Exchange(ctx, cb.Get("code"), oauth2.VerifierOption(verifier))
	fmt.Println("exchange with the right verifier:   err =", err, " type =", tok.TokenType)
	user := conf.Client(ctx, tok)
	fmt.Println("GET  /v1/tasks:                    ", get(user, srv.URL+"/v1/tasks"))

	_, err = conf.Exchange(ctx, cb.Get("code"), oauth2.VerifierOption(verifier))
	fmt.Println("the same code a second time:       ", short(err))
	cb2 := authorize(authURL, "ada")
	_, err = conf.Exchange(ctx, cb2.Get("code"), oauth2.VerifierOption(oauth2.GenerateVerifier()))
	fmt.Println("a stolen code with the wrong verifier:", short(err))

	fmt.Println("\n== things the authorization endpoint refuses")
	evil := strings.Replace(authURL, "com.example.tasks%3A%2Fcallback", "https%3A%2F%2Fevil.example%2Fcb", 1)
	fmt.Println("redirect_uri not registered:       ", authorize(evil, "ada"))
	noPKCE := strings.Replace(strings.Replace(authURL, "code_challenge_method=S256", "", 1), "&&", "&", 1)
	fmt.Println("no PKCE:                           ", authorize(noPKCE, "ada").Get("error_description"))
	fmt.Println("scope the client may not ask for:  ", authorize(strings.Replace(authURL, "tasks%3Aread", "tasks%3Awrite", 1), "ada").Get("error"))
	fmt.Println("user declines (no login):          ", authorize(authURL, "").Get("error"))
}

func pick(raw string, keys ...string) []string {
	u, _ := url.Parse(raw)
	var out []string
	for _, k := range keys {
		out = append(out, k+"="+u.Query().Get(k))
	}
	return out
}

func short(err error) string {
	if err == nil {
		return "nil"
	}
	if re, ok := err.(*oauth2.RetrieveError); ok {
		return fmt.Sprintf("%d %s", re.Response.StatusCode, strings.TrimSpace(string(re.Body)))
	}
	return err.Error()
}
Text
== client credentials: a machine acting as itself
GET  /v1/tasks (scope tasks:read):   200 tasks visible to billing-job
POST /v1/tasks (needs tasks:write):  403 Bearer error="insufficient_scope", scope="tasks:write"
wrong client secret:                oauth2: "invalid_client"

== authorization code with PKCE: a user, through an app that cannot keep a secret
authorize URL carries: response_type=code client_id=mobile-app code_challenge_method=S256 state=xyz-state
redirected back with state = xyz-state and a code of 43 characters
exchange with the right verifier:   err = <nil>  type = Bearer
GET  /v1/tasks:                     200 tasks visible to ada
the same code a second time:        400 {"error":"invalid_grant"}
a stolen code with the wrong verifier: 400 {"error":"invalid_grant","error_description":"PKCE verification failed"}

== things the authorization endpoint refuses
redirect_uri not registered:        map[status:[400]]
no PKCE:                            PKCE with S256 is required
scope the client may not ask for:   invalid_scope
user declines (no login):           access_denied

The client side uses golang.org/x/oauth2, the library most Go programs use. (The latest releases of this module needed a newer Go toolchain than the one in this test, so the lab pins v0.34.0.)

Client credentials. clientcredentials.Config fetches a token from the token endpoint and wraps an HTTP client that attaches it. GET /v1/tasks with the tasks:read scope returned tasks visible to billing-job, so the token's subject is the client. POST /v1/tasks needs tasks:write, which this token did not request, and got a 403 with insufficient_scope. A wrong client secret failed with invalid_client.

Authorization code with PKCE. The program generates a verifier with oauth2.GenerateVerifier, builds the authorization URL with an S256 challenge, and plays the browser: a request to /authorize with no redirect following, as the demo user. The response is a redirect back to the app's own URI with the same state and a 43-character code. Exchange with the verifier succeeds and returns a bearer token, and GET /v1/tasks now shows tasks visible to ada: the subject is the user, as she consented. The same code used a second time fails with invalid_grant. A second code redeemed with a different, freshly generated verifier, what an attacker with an intercepted code would have, fails with PKCE verification failed.

What the authorize endpoint refuses. A redirect URI that is not registered gives a 400 with no redirect. A request without PKCE comes back as PKCE with S256 is required. A scope the client is not allowed to ask for is invalid_scope, and a user who declines is access_denied.

Tests

oauth_test.goGo
package main

import (
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"

	"golang.org/x/oauth2"
)

func rig(t *testing.T) (*httptest.Server, *time.Time) {
	t.Helper()
	clock := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
	srv, _ := world(func() time.Time { return clock })
	t.Cleanup(srv.Close)
	return srv, &clock
}

func authorizeRaw(srv *httptest.Server, params url.Values, user string) *http.Response {
	req, _ := http.NewRequest("GET", srv.URL+"/oauth/authorize?"+params.Encode(), nil)
	req.Header.Set("X-Demo-User", user)
	c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
	res, _ := c.Do(req)
	return res
}

func params(redirect, verifier string) url.Values {
	return url.Values{
		"response_type": {"code"}, "client_id": {"mobile-app"}, "redirect_uri": {redirect}, "scope": {"tasks:read"},
		"state": {"s"}, "code_challenge": {oauth2.S256ChallengeFromVerifier(verifier)}, "code_challenge_method": {"S256"},
	}
}

func TestRedirectURIMustMatchExactly(t *testing.T) {
	srv, _ := rig(t)
	v := oauth2.GenerateVerifier()
	for _, uri := range []string{"com.example.tasks:/callback/", "com.example.tasks:/callback?x=1", "com.example.tasks:/", "https://evil.example/cb", ""} {
		if res := authorizeRaw(srv, params(uri, v), "ada"); res.StatusCode != 400 {
			t.Errorf("%q -> %d, want 400 and no redirect", uri, res.StatusCode)
		}
	}
	if res := authorizeRaw(srv, params("com.example.tasks:/callback", v), "ada"); res.StatusCode != 302 {
		t.Errorf("the registered URI -> %d", res.StatusCode)
	}
}

func exchange(srv *httptest.Server, form url.Values, basic ...string) *http.Response {
	req, _ := http.NewRequest("POST", srv.URL+"/oauth/token", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	if len(basic) == 2 {
		req.SetBasicAuth(basic[0], basic[1])
	}
	res, _ := http.DefaultClient.Do(req)
	return res
}

func TestExpiredCodeIsRefused(t *testing.T) {
	srv, clock := rig(t)
	v := oauth2.GenerateVerifier()
	loc, _ := url.Parse(authorizeRaw(srv, params("com.example.tasks:/callback", v), "ada").Header.Get("Location"))
	*clock = clock.Add(2 * time.Minute)
	res := exchange(srv, url.Values{"grant_type": {"authorization_code"}, "client_id": {"mobile-app"}, "code": {loc.Query().Get("code")},
		"redirect_uri": {"com.example.tasks:/callback"}, "code_verifier": {v}})
	if res.StatusCode != 400 {
		t.Fatalf("got %d", res.StatusCode)
	}
}

func TestPublicClientsCannotUseClientCredentials(t *testing.T) {
	srv, _ := rig(t)
	res := exchange(srv, url.Values{"grant_type": {"client_credentials"}, "client_id": {"mobile-app"}})
	if res.StatusCode != 400 {
		t.Fatalf("got %d", res.StatusCode)
	}
}

func TestTokenResponsesAreNeverCached(t *testing.T) {
	srv, _ := rig(t)
	res := exchange(srv, url.Values{"grant_type": {"client_credentials"}}, "billing-job", "s3cret")
	if res.StatusCode != 200 || res.Header.Get("Cache-Control") != "no-store" {
		t.Fatalf("%d %q", res.StatusCode, res.Header.Get("Cache-Control"))
	}
}

func TestWrongSecretIsA401WithAChallenge(t *testing.T) {
	srv, _ := rig(t)
	res := exchange(srv, url.Values{"grant_type": {"client_credentials"}}, "billing-job", "nope")
	if res.StatusCode != 401 || res.Header.Get("WWW-Authenticate") == "" {
		t.Fatalf("%d", res.StatusCode)
	}
}
Text
=== RUN   TestRedirectURIMustMatchExactly
--- PASS: TestRedirectURIMustMatchExactly (0.01s)
=== RUN   TestExpiredCodeIsRefused
--- PASS: TestExpiredCodeIsRefused (0.00s)
=== RUN   TestPublicClientsCannotUseClientCredentials
--- PASS: TestPublicClientsCannotUseClientCredentials (0.00s)
=== RUN   TestTokenResponsesAreNeverCached
--- PASS: TestTokenResponsesAreNeverCached (0.00s)
=== RUN   TestWrongSecretIsA401WithAChallenge
--- PASS: TestWrongSecretIsA401WithAChallenge (0.00s)
PASS
ok  	example.test/lab	1.323s

Five tests cover the checks that matter most. The redirect URI must match exactly: a trailing slash, an added query, the bare scheme, another host and an empty value are all refused with a 400, and only the registered string redirects. Prefix and pattern matching are how many real-world OAuth bugs happened. An expired code is refused, with the clock injected. A public client cannot use client credentials. Token responses are no-store. A wrong secret is a 401 with a challenge.

The attacks each check answers

Reading an OAuth server is easier with the attack beside each line of code.

Authorization code interception. On a phone, another app can register the same custom URL scheme and receive the redirect with the code. PKCE makes the stolen code useless, because redeeming it needs the verifier that never left the legitimate app. This is the attack that made PKCE mandatory for public clients.

Redirect URI manipulation. If the server accepted any URI that starts with a registered prefix, an attacker could send a user to https://app.example.com.evil.example/ or to an open redirect on the real domain and receive the code there. Exact string matching is the only matching that cannot be tricked, and the test tries the near misses.

Cross-site request forgery on the callback. The state value is the client's defence: it generates a random value, stores it, sends it on the authorize request and checks it when the redirect arrives. The server's only job is to return it unchanged, which the redirect helper does. A client that skips the check can be made to complete a flow that the attacker started.

Code replay. A code observed in logs or a browser history is dangerous for as long as it is valid. One minute of life and deletion on first use shrink that window, and a failed PKCE check still burns the code.

Token leakage in URLs. Tokens go in the Authorization header and in the body of the token response, never in a query string. That is why the implicit grant, which returned a token in the redirect fragment, is gone.

Over-broad scopes. Each client is registered with the scopes it may ask for, and the server refuses others at the authorize step. The consent screen then shows what is being asked. Scopes are the product's vocabulary: tasks:read is clear, all is not.

Registering clients

Client registration is data, not code: an id, a hashed secret for confidential clients, exact redirect URIs, allowed grants and allowed scopes. The demo holds two in memory. A real system keeps them in a database, hashes secrets the way API keys are hashed, and lets you rotate a secret by allowing two for a while. Public clients have no secret, and the server cannot tell the genuine app from a copy, which is exactly why it leans on PKCE and exact redirect URIs. Native apps should use claimed HTTPS redirects (app links, universal links) where the platform supports them; custom schemes can be hijacked, as described above.

Token format

The access tokens here are JWTs because the resource server then needs no call to the authorization server. The alternative is an opaque random string that the API checks by calling an introspection endpoint or looking it up in a shared store. Opaque tokens can be revoked at once and cost a lookup per request; JWTs are cheap to check and live until they expire. Many systems use JWTs for access (short) and opaque tokens for refresh (long), which is the arrangement the earlier article built.

What this server does not do

A production server also needs: a real login and consent screen with CSRF protection; refresh tokens with rotation and reuse detection (see refresh tokens); a revocation endpoint (RFC 7009); token introspection for opaque tokens (RFC 7662); a JWKS endpoint and key rotation; persistent storage for clients, codes and consents; rate limiting on /token (rate limiting); audit logging; and for sign-in rather than API access, OpenID Connect on top, which adds an ID token and a user info endpoint.

That is a lot of surface area to get exactly right, and it is why buying or adopting is often better than building. Maintained options include Ory Hydra and Fosite, the zitadel/oidc library and hosted providers; evaluate them with the same questions used in this article, whether they require PKCE, match redirects exactly and make codes single-use. The authorization framework is specified in RFC 6749, and the current security guidance, RFC 9700, is worth reading in full before you ship anything. The client library is documented at pkg.go.dev/golang.org/x/oauth2.

Also inSecurity
Taggedsecurity
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.