Building OAuth 2.0 From Scratch in Go — Part 1: The Authorization Code Flow
You’ve clicked “Log in with Google” a hundred times. This is what’s happening on the other side — built from scratch, in three small Go servers you can run yourself.
Why build it instead of reading about it
OAuth 2.0 has a reputation for being confusing, and I think the reason is that most explanations start with the vocabulary — “resource owner,” “bearer token,” “grant type” — before you have anything to hang it on. So I did the opposite: I built the whole thing. Three tiny Go programs, three roles, one flow, end to end. By the time the photos showed up in the browser, the vocabulary had explained itself.
This is Part 1: the Authorization Code grant with self-contained JWT access tokens, using only the Go standard library plus one JWT package. Part 2 will upgrade it to the asymmetric (RS256) signing that real providers like Google use, and explain why.
If you want to follow along, you need Go and about an afternoon.
The problem OAuth actually solves
Picture an app — call it PhotoPrint — that wants to print the photos you keep in Google Photos.
The naive approach is to ask for your Google password. That’s a catastrophe: now PhotoPrint can read your email, change your password, delete your account. You can’t grant it just photo access, and you can’t revoke it without changing your password everywhere.
OAuth 2.0 exists to answer one question:
How do I let an app do one specific thing on my behalf, without giving it my password — and revoke that access later?
The hotel analogy
The mental model that made it click for me is a hotel:
- You check in — you’re the Resource Owner.
- The front desk verifies you and hands out a keycard. That’s the Authorization Server.
- The keycard opens your room and the gym, but not the safe or other rooms. It expires at checkout. It’s a token: scoped, time-limited, revocable.
- The door lock just checks whether the keycard is valid for that door. It never sees your name or password. That’s the Resource Server.
The whole trick is that the door lock doesn’t need to know who you are. The front desk handles identity once and issues limited keycards. That separation is OAuth.
The four roles
| Role | Hotel | Our project |
|---|---|---|
| Resource Owner | The guest (you) | The user in their browser |
| Client | A friend fetching something from your room | The app wanting access (PhotoPrint) |
| Authorization Server | Front desk issuing keycards | Issues tokens after login + consent |
| Resource Server | The door lock | The API holding protected data |
The flow, in six steps
The Authorization Code grant looks like this:
- The client redirects the user’s browser to the authorization server: “this user wants to grant me access.”
- The user logs in and clicks Allow.
- The auth server redirects the browser back to the client with a short-lived authorization code.
- The client — now server-to-server, behind the scenes — exchanges that code for an access token.
- The client calls the resource server, presenting the token.
- The resource server validates the token and returns the data.
The single most important idea here, and the one that took me longest to internalize:
The code travels through the browser. The valuable token is exchanged for it over a direct server-to-server channel the browser never sees.
This split — the front channel (browser, redirects) versus the back channel (server-to-server, secrets) — is the backbone of the whole design. Hold onto it.
A fork in the road: how does the resource server trust the token?
Before writing code, there’s a decision that shapes everything: when the resource server gets a token, how does it know the token is real and what it allows?
There are two answers.
Option A — opaque tokens. The token is a meaningless random string. The auth server keeps a record of what it maps to. The resource server has to ask the auth server “is this valid?” on every request (an endpoint called token introspection). Easy to revoke; costs a network round-trip per call.
Option B — self-contained tokens (JWT). The token is the data — {user, scope, expiry} — cryptographically signed by the auth server. The resource server verifies the signature with a key it already has, reads the claims, and trusts them. No network call. Fast, but hard to revoke before expiry (so JWTs are short-lived).
I went with Option B, because you can literally see the data inside the token and watch the signature verification happen. And it surfaces the key insight either way:
The resource server trusts tokens because of a relationship established out of band, before any user shows up — a shared signing key (Option B) or a shared lookup mechanism (Option A). The trust isn’t in the token by magic; it’s in the prior agreement between the servers.
Building it: three programs
The project is three independent Go programs, each its own main, on its own port. They simulate three separate parties — so they don’t share code, on purpose.
authserver/ :9000 the Authorization Server
resourceserver/ :9100 the Resource Server (protected API)
client/ :8080 the Client app (PhotoPrint)
The Authorization Server
It has three endpoints, matching steps 1–4 of the flow.
GET /authorize receives the client’s request and renders the consent screen — but first it validates. This is where I learned my first real lesson about OAuth security. A client sends a redirect_uri telling the server where to send the code back. If the server blindly trusts it:
/authorize?client_id=photoprint&redirect_uri=http://evil.com/steal&...
…an attacker just redirected the authorization code to their own server. This is the open redirect vulnerability. The defense is that every client is pre-registered with the exact redirect URIs it’s allowed to use, and the server checks for an exact match:
client, ok := registeredClients[clientID]
if !ok {
http.Error(w, "unknown client_id", http.StatusBadRequest)
return
}
if redirectURI != client.RedirectURI { // exact match, no wildcards
http.Error(w, "redirect_uri mismatch", http.StatusBadRequest)
return
}
When validation passes, the server renders an HTML consent page. There’s a subtlety here that bit me: the consent form POSTs to a different endpoint, and that POST is a brand-new request with no query string. So the OAuth parameters have to ride along as hidden form fields, or they’re gone:
<form method="POST" action="/approve">
<input type="hidden" name="client_id" value="...">
<input type="hidden" name="redirect_uri" value="...">
<input type="hidden" name="scope" value="...">
<input type="hidden" name="state" value="...">
<button name="decision" value="allow">Allow</button>
<button name="decision" value="deny">Deny</button>
</form>
POST /approve handles the decision. On Allow, it mints an authorization code and redirects back to the client. The code has strict rules: random and unguessable (crypto/rand, never math/rand), short-lived (mine expire in 2 minutes), single-use, and bound to the client and scope. The code itself is opaque — it’s just a lookup key into a server-side store:
code := generateCode() // crypto/rand → base64 URL-safe string
issuedCodes[code] = AuthCode{
ClientID: clientID,
Scope: scope,
ExpiresAt: time.Now().Add(2 * time.Minute),
}
This handler is where I hit the bug I’m most glad I made. My deny branch looked like this:
if decision != "allow" {
http.Redirect(w, r, redirectURI+"?error=access_denied", http.StatusFound)
}
// ...execution keeps going here!
code := generateCode()
http.Redirect does not stop the function — it only writes response headers. Without a return, execution falls straight through and mints a code anyway. A user who clicked Deny would get access. One line fixes it:
if decision != "allow" {
http.Redirect(w, r, redirectURI+"?error=access_denied&state="+state, http.StatusFound)
return // ← stop here
}
Takeaway: always
returnafter writing a redirect or an error in an HTTP handler. The lack of one is a silent, dangerous fall-through.
POST /token is the back channel — the climax of the auth server. The client exchanges its code, plus a client_secret, for a real token. The whole reason this happens server-to-server is that secret:
The
client_secretis PhotoPrint’s master password. It must never touch the browser. So the client’s backend calls/tokendirectly, secret in hand, out of the browser’s view.
The endpoint runs its checks — correct grant type, code exists, not expired, issued to this client, secret matches — then deletes the code (single-use) and mints a JWT:
now := time.Now()
claims := jwt.MapClaims{
"sub": "user-42", // the resource owner (we fake a user; no login yet)
"scope": authCode.Scope,
"iss": issuer,
"aud": "resource-server",
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
}
signed, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret)
A JWT is three base64 chunks joined by dots — header.payload.signature. The payload is just base64; anyone can read it. It is signed, not encrypted. The signature is HMAC-SHA256(header.payload, secret), and only someone with the secret can produce a valid one. Tamper with the payload and the signature no longer matches. Paste the token into jwt.io and you can watch your own claims decode — that’s the “aha” of self-contained tokens.
The Resource Server
This is the smallest and most satisfying program, because it proves Option B works. It pulls the token from the Authorization: Bearer <jwt> header, verifies the signature locally — no call back to the auth server — and checks the scope:
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
// SECURITY: pin the algorithm. Without this an attacker could present a
// token signed with a different (or "none") algorithm and bypass verification.
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return jwtSecret, nil // the shared secret IS the trust
})
if err != nil || !token.Valid {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
That algorithm check is not optional. The “alg confusion” attack — where an attacker swaps the algorithm to none or to one the server mishandles — is a real, exploited JWT vulnerability. The keyfunc is exactly where you defend against it.
A valid token returns the photos; a garbage token gets a 401; and the auth server is never contacted. That last part is the whole point.
The Client
This is the part you actually experience. Three endpoints: a home page with a “Connect your photos” button, a /login that redirects the browser into the auth server, and a /callback where everything comes together.
/login finally puts the state parameter to work. The client generates a random state, remembers it, and includes it in the redirect. When the callback comes back, the client checks that the state matches one it issued — which means an attacker can’t forge a callback, because they can’t guess your state. That’s CSRF protection:
state := randomString()
pendingStates[state] = true
params := url.Values{
"response_type": {"code"},
"client_id": {clientID},
"redirect_uri": {redirectURI},
"scope": {scope},
"state": {state},
}
http.Redirect(w, r, authServer+"/authorize?"+params.Encode(), http.StatusFound)
(Building query strings with url.Values{}.Encode() instead of hand-concatenating "?code="+code is the right habit — it URL-encodes values safely.)
/callback verifies the state, then runs the back channel: it POSTs the code and secret to /token, parses the JSON for the access token, and calls the resource server with it:
if !pendingStates[state] {
http.Error(w, "invalid state", http.StatusBadRequest)
return
}
delete(pendingStates, state) // single-use
token, _ := exchangeCodeForToken(code) // back-channel POST to /token
photos, _ := fetchPhotos(token) // GET /photos with Bearer header
fmt.Fprintf(w, "<h1>✅ Connected!</h1><p>%s</p>", photos)
Run all three servers, open http://localhost:8080, click the button, and you get bounced to a consent screen on :9000, click Allow, and land back on the client showing:
✅ Connected!
📷 Protected photos for user user-42: [beach.jpg, cat.jpg, sunset.jpg]
That’s the entire OAuth 2.0 Authorization Code flow, running on your own machine.
The bugs that taught me the most
The happy path is forgettable. These are the things I’ll actually remember.
1. Silent string mismatches between services. Twice, a typo’d string broke everything with no error. First, reading redirect_url when the spec parameter is redirect_uri — r.URL.Query().Get() returns "" for a missing key, so it failed silently. Later, the auth server issued scope read:photos while the resource server checked for photos:read, so valid tokens got a 403. Lesson: string contracts between services have to match exactly, and a missing value looks identical to an empty one. Standardize on the spec’s names everywhere.
2. http.Redirect doesn’t return. Covered above — the missing return in the deny path let denied users through. The most instructive bug of the project.
3. fmt.Print vs fmt.Fprintf. At one point my /token endpoint returned an empty 200 with no body. The cause: fmt.Print(w, "...%s", x) writes to stdout, not to w, and ignores format verbs entirely. The output was going to the server’s terminal. The fix is fmt.Fprintf(w, ...) — F for “to a writer,” f for “interpret the verbs.” Empty response body? Check whether you’re actually writing to w.
4. The wrong JWT library. My IDE’s auto-import pulled in github.com/dgrijalva/jwt-go — which is deprecated, archived, and had CVEs — instead of the maintained fork, github.com/golang-jwt/jwt/v5. The build broke, and that was lucky; the worse outcome is shipping an abandoned crypto library. Lesson: watch what your editor auto-imports, especially for security-sensitive packages.
5. Accidental nested Go modules. This one cost me the most time. At some point a go mod init/go get had run inside authserver/ and resourceserver/, leaving stray go.mod files there. Each subdirectory became its own module, so the parent module reported does not contain package gooauth/authserver, and go mod tidy kept silently dropping a dependency I was obviously importing. The symptom is bizarre — a package “doesn’t exist” even though the file is right in front of you. The fix was deleting the nested go.mod/go.sum so all three programs live under one root module. Lesson: if go mod tidy drops a dependency you clearly use, or a package mysteriously “doesn’t exist,” run find . -name go.mod and look for a nested module boundary.
What’s deliberately missing (and coming in Part 2)
This is a learning build, not production. Conscious omissions: there’s no real user login (the sub claim is hardcoded), tokens are stored in memory, there are no refresh tokens, and there’s no PKCE (the protection modern public clients need). I’ll add those as the series goes.
The biggest limitation is the one Part 2 opens with. We used HS256 — a single shared secret that both signs and verifies. That has a fatal flaw at scale: a key that can verify a token can also forge one. You can’t hand that secret to thousands of resource servers. Real providers like Google use RS256 — an asymmetric key pair where the auth server signs with a private key and resource servers verify with a public key that can verify but never sign. That’s how Google can publish its verification keys to the entire internet without anyone being able to forge a token.
That’s Part 2.
Source code
- Authorization, resource, and client code: https://github.com/paudelanil/anil-blog-lab/tree/main/gooauth