Build a Token-Based Auth SDK in Go
Build a Go SDK with token caching, retry logic, and auth middleware.
Introduction
30 Second Summary
Every time you log in to an app, a token is working behind the scenes. It proves you are who you say you are without sending your password on every single request.
In this project, you will build a complete token-based authentication system in Go. You'll create a token server, a reusable client SDK with caching and retry logic, and middleware that protects API routes.
What You'll Build
A self-contained authentication system running on your machine with a token server that issues signed tokens and a client SDK library that communicates with it.
By the end of this project, you'll have:
- A working token server that issues and validates signed authentication tokens.
- A reusable SDK client with token caching, configurable retry logic with exponential backoff, and custom error handling.
- Auth middleware that protects API endpoints using Bearer tokens.
- Secret Mission: Add automatic token refresh that renews tokens in the background before they expire.
Are there any prerequisites?
You only need basic programming concepts like variables, functions, and if/else logic. No Go or backend experience is needed.
Set Up the Project
Your authentication SDK has four distinct components: a token server, the SDK library itself, a protected API, and an example client. Each one lives in its own directory so they stay organized and independent.
In this step, you will create the project folder, set up a Go module, and run a quick test to confirm everything is working.
In this step, get ready to:
- Create the project folder and initialize a Go module.
- Set up subdirectories for each component.
- Verify the Go toolchain by running a test file.
Create the project folder and initialize the module
A Go module is a collection of related Go packages versioned together. The go mod init command creates a go.mod file that tracks your module name and any dependencies.
- In your terminal, move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and move into it by running these commands:
mkdir authsdk
cd authsdk
- Initialize a Go module by running:
go mod init authsdk
What does this command do?
This creates a go.mod file in your authsdk folder. The module name authsdk tells Go how to refer to packages within your project.
- Create the subdirectories for each component by running:
mkdir "server", "sdk", "protected", "example"
- Confirm everything was created by running:
ls
- You should see go.mod alongside four directories: server, sdk, protected, and example.
Don't see go.mod?
Make sure you ran go mod init authsdk from inside the authsdk folder, not from your Desktop. Run cd ~/Desktop/authsdk to make sure you are in the right place, then try again.
help me debug my go mod init command
Write a test file and verify the toolchain
The go.mod file confirms the module is set up. Now let's make sure Go can compile and run code from your project.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the authsdk folder on your Desktop.
- Click Select Folder.
You should see the four subdirectories and go.mod listed in the Explorer sidebar on the left.
- In the Explorer sidebar, click on the server folder to select it.
- Click the New File icon at the top of the Explorer sidebar (it looks like a page with a plus sign).
- Type main.go and press Enter.
- Paste the following code into the new file:
package main
import "fmt"
func main() {
fmt.Println("Hello from server")
}
What does this code do?
- package main tells Go this file is an executable program (not a library).
- import "fmt" brings in Go's formatting package for printing output.
- func main() is the entry point. Go runs this function when you execute the program.
- Save server/main.go by pressing Ctrl+S.
- Open the integrated terminal in VS Code by pressing Ctrl+` (the backtick key, above Tab).
- Run the test file by entering:
go run ./server
- You should see Hello from server printed in the terminal.
Seeing an error instead?
- If you see a "cannot find module" error, make sure the terminal is in the authsdk folder (not inside server/). The integrated terminal should open here by default since you opened this folder in VS Code.
- If you see a syntax error, check that your code in server/main.go matches the code above exactly. Go is strict about formatting and missing characters.
help me fix my go run error
✔️ Awesome, I've got everything!
Great. Double check you have saved server/main.go and you are ready for the next step.
ⓧ I'd like to double check the full code
Here is the complete server/main.go file for comparison:
package main
import "fmt"
func main() {
fmt.Println("Hello from server")
}
Your project structure is ready and the Go toolchain is working. Next up, you will build the token server that issues and validates authentication tokens.
Build the Token Server
Your SDK needs something to talk to. Before building the client library, you need a server that can issue and validate authentication tokens.
In this step, you will build a token server that signs tokens using HMAC-SHA256 cryptography and exposes two HTTP endpoints for issuing and validating those tokens.
In this step, get ready to:
- Set up token signing and verification using HMAC-SHA256.
- Build HTTP handlers that issue and validate tokens.
- Test the running server with real HTTP requests.
Set up the data model and token signing
The token server needs data structures to represent login requests and responses, plus cryptographic functions to create and verify tokens.
- In VS Code, open server/main.go from the Explorer sidebar.
- Select all the existing code and delete it.
- Replace it with the following package declaration, imports, configuration, and type definitions:
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
)
// Secret key used to sign and verify tokens
var secretKey = []byte("my-secret-signing-key")
// Hardcoded user credentials for demonstration
var users = map[string]string{
"alice": "password123",
"bob": "securepw456",
}
// LoginRequest represents the JSON body for token requests
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// LoginResponse is returned when authentication succeeds
type LoginResponse struct {
Token string `json:"token"`
ExpiresIn int `json:"expires_in"`
}
// ErrorResponse is returned for any failed request
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
// TokenPayload holds the data embedded inside a signed token
type TokenPayload struct {
Username string `json:"username"`
ExpiresAt int64 `json:"expires_at"`
}
What does this code do?
This sets up the foundation for your token server.
- secretKey is the HMAC signing key. Every token is signed with this key so the server can verify tokens it issued.
- users is a hardcoded credential store. In production this would be a database lookup.
- The four struct types define the shapes of JSON data flowing in and out of the server.
You will not see a change yet. The next chunk adds the signing functions that make these types useful.
- Directly below the type definitions, add the token signing and verification functions:
func signToken(payload TokenPayload) string {
// Convert the payload struct to JSON bytes
payloadBytes, _ := json.Marshal(payload)
// Base64-encode the JSON payload
encoded := base64.URLEncoding.EncodeToString(payloadBytes)
// Create an HMAC-SHA256 signature of the encoded payload
mac := hmac.New(sha256.New, secretKey)
mac.Write([]byte(encoded))
signature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
// Return the token as payload.signature
return encoded + "." + signature
}
func verifyToken(token string) (*TokenPayload, error) {
// Split the token into payload and signature parts
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid token format")
}
encoded := parts[0]
signature := parts[1]
// Recompute the expected signature
mac := hmac.New(sha256.New, secretKey)
mac.Write([]byte(encoded))
expectedSig := base64.URLEncoding.EncodeToString(mac.Sum(nil))
// Timing-safe comparison to prevent timing attacks
if !hmac.Equal([]byte(signature), []byte(expectedSig)) {
return nil, fmt.Errorf("invalid signature")
}
// Decode the payload from base64
payloadBytes, err := base64.URLEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("invalid payload encoding")
}
// Unmarshal the JSON payload
var payload TokenPayload
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return nil, fmt.Errorf("invalid payload")
}
// Check if the token has expired
if time.Now().Unix() > payload.ExpiresAt {
return nil, fmt.Errorf("token expired")
}
return &payload, nil
}
How does the token format work?
The token format is base64(payload).base64(signature). This mirrors how JWT tokens work in production systems.
- signToken encodes the username and expiry time into JSON, then signs it with HMAC-SHA256. The signature proves the token came from this server.
- verifyToken splits the token at the dot, recomputes the signature, and compares them. If they match, the token is authentic and untampered.
- hmac.Equal uses a constant-time comparison to prevent timing attacks where an attacker measures response speed to guess the signature.
You will not see a change yet. The next chunk adds the HTTP handlers that use these functions.
Add the HTTP handlers and start the server
Now you need HTTP handlers that accept requests, validate credentials, and return tokens. These handlers use the signing functions you just wrote.
- Below the verifyToken function, add the handleToken handler that processes login requests:
func handleToken(w http.ResponseWriter, r *http.Request) {
// Only accept POST requests
if r.Method != http.MethodPost {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "method_not_allowed",
Message: "Only POST is allowed",
})
return
}
// Parse the JSON login request body
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "invalid_request",
Message: "Invalid JSON body",
})
return
}
// Check credentials against the users map
storedPassword, exists := users[req.Username]
if !exists || storedPassword != req.Password {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "invalid_credentials",
Message: "Invalid username or password",
})
return
}
// Create a token payload with 300-second expiry
expiresIn := 300
payload := TokenPayload{
Username: req.Username,
ExpiresAt: time.Now().Unix() + int64(expiresIn),
}
// Sign and return the token
token := signToken(payload)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(LoginResponse{
Token: token,
ExpiresIn: expiresIn,
})
}
What does handleToken do?
- It rejects anything that is not a POST request.
- It decodes the JSON body into a LoginRequest struct.
- It checks the username and password against the users map.
- If credentials are valid, it creates a signed token with a 300-second (5-minute) expiry and returns it.
You will not see a change yet. The next chunk adds the validation handler and starts the server.
- Below handleToken, add the handleValidate handler and the main function:
func handleValidate(w http.ResponseWriter, r *http.Request) {
// Only accept GET requests
if r.Method != http.MethodGet {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "method_not_allowed",
Message: "Only GET is allowed",
})
return
}
// Extract the Bearer token from the Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "missing_token",
Message: "Authorization header with Bearer token required",
})
return
}
// Strip the "Bearer " prefix to get the raw token
token := strings.TrimPrefix(authHeader, "Bearer ")
payload, err := verifyToken(token)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "invalid_token",
Message: err.Error(),
})
return
}
// Token is valid - return the username
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "valid",
"username": payload.Username,
})
}
func main() {
// Create a new request multiplexer and register routes
mux := http.NewServeMux()
mux.HandleFunc("/token", handleToken)
mux.HandleFunc("/validate", handleValidate)
fmt.Println("Token server running on http://localhost:9090")
log.Fatal(http.ListenAndServe(":9090", mux))
}
What does this code do?
- handleValidate extracts the Bearer token from the Authorization header, runs it through verifyToken, and returns the username if the token is valid.
- The main function creates a router, registers both endpoints, and starts an HTTP server on port 9090.
- log.Fatal ensures the program exits with an error message if the server fails to start (for example, if port 9090 is already in use).
- Save server/main.go.
Seeing a red underline or compile error?
- Make sure you deleted all the original placeholder code before pasting. The old import "fmt" line will conflict with the new import block.
- Check that every opening brace { has a matching closing brace }. Go requires braces on the same line as the function signature.
- help me fix a compile error in my server/main.go
✔️ Awesome, I've got everything!
Great. Make sure you have saved server/main.go before moving on.
ⓧ I'd like to double check the full code
Here is the complete server/main.go file. Make sure yours matches exactly:
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
)
var secretKey = []byte("my-secret-signing-key")
var users = map[string]string{
"alice": "password123",
"bob": "securepw456",
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
ExpiresIn int `json:"expires_in"`
}
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
type TokenPayload struct {
Username string `json:"username"`
ExpiresAt int64 `json:"expires_at"`
}
func signToken(payload TokenPayload) string {
payloadBytes, _ := json.Marshal(payload)
encoded := base64.URLEncoding.EncodeToString(payloadBytes)
mac := hmac.New(sha256.New, secretKey)
mac.Write([]byte(encoded))
signature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
return encoded + "." + signature
}
func verifyToken(token string) (*TokenPayload, error) {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid token format")
}
encoded := parts[0]
signature := parts[1]
mac := hmac.New(sha256.New, secretKey)
mac.Write([]byte(encoded))
expectedSig := base64.URLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedSig)) {
return nil, fmt.Errorf("invalid signature")
}
payloadBytes, err := base64.URLEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("invalid payload encoding")
}
var payload TokenPayload
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return nil, fmt.Errorf("invalid payload")
}
if time.Now().Unix() > payload.ExpiresAt {
return nil, fmt.Errorf("token expired")
}
return &payload, nil
}
func handleToken(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "method_not_allowed",
Message: "Only POST is allowed",
})
return
}
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "invalid_request",
Message: "Invalid JSON body",
})
return
}
storedPassword, exists := users[req.Username]
if !exists || storedPassword != req.Password {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "invalid_credentials",
Message: "Invalid username or password",
})
return
}
expiresIn := 300
payload := TokenPayload{
Username: req.Username,
ExpiresAt: time.Now().Unix() + int64(expiresIn),
}
token := signToken(payload)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(LoginResponse{
Token: token,
ExpiresIn: expiresIn,
})
}
func handleValidate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "method_not_allowed",
Message: "Only GET is allowed",
})
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "missing_token",
Message: "Authorization header with Bearer token required",
})
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
payload, err := verifyToken(token)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(ErrorResponse{
Error: "invalid_token",
Message: err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "valid",
"username": payload.Username,
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/token", handleToken)
mux.HandleFunc("/validate", handleValidate)
fmt.Println("Token server running on http://localhost:9090")
log.Fatal(http.ListenAndServe(":9090", mux))
}
Test the token server
Your server is complete. Time to start it up and confirm both endpoints work with real HTTP requests.
- Start the token server by running this command in your terminal:
go run ./server
- You should see Token server running on http://localhost:9090 printed in the terminal.
- Keep this terminal running. Open a second terminal in VS Code by pressing Ctrl+Shift+`.
Don't see the server message?
- If you see a compile error, double check that server/main.go matches the full code in the tab above. Missing imports or unmatched braces are the most common cause.
- If the terminal says port 9090 is already in use, another process is occupying that port. Close other running Go programs and try again.
- help me debug why my Go server won't start
- In the second terminal, issue a token by running this PowerShell command:
Invoke-RestMethod -Method Post -Uri http://localhost:9090/token -Body '{"username":"alice","password":"password123"}' -ContentType "application/json"
What should I see?
You should see a response with two fields: a long token string (the base64-encoded payload and signature separated by a dot) and expires_in set to 300.
- Copy the token value from the response. You will need it for the next command.
- Validate that token by running this command (replace the token placeholder with your actual token):
Invoke-RestMethod -Uri http://localhost:9090/validate -Headers @{Authorization="Bearer [[TOKEN="your token from the previous response"]]"}
What should I see?
You should see a response with status set to valid and username set to alice. This confirms your server can both issue and verify tokens.
Getting an error from the validate command?
- Make sure you replaced the placeholder with the actual token string from the previous response. The token is the long string of characters containing a dot in the middle.
- Check that there are no extra spaces or line breaks inside the token value.
- If you waited more than 5 minutes between issuing and validating, the token has expired. Issue a new one with the first command and try again.
- help me troubleshoot my token validation request
Your token server is issuing signed tokens and verifying them cryptographically. Next up, you will build the SDK client library that automates this authentication flow and adds token caching.
Build the SDK Client with Token Caching
Your token server is issuing and validating tokens. Now you need a way for other programs to use it without manually crafting HTTP requests every time.
In this step, you will build an SDK client library that wraps the token server's API into a clean, reusable interface. The key feature is token caching. Instead of hitting the network on every call, the SDK remembers its last token and only requests a new one when the old one expires.
In this step, get ready to:
- Create the SDK's Client struct and configurable constructor.
- Implement token caching with thread-safe access.
- Test the cache behavior with an example client.
Create the Client struct and constructor
A production SDK needs to hold configuration (server URL, credentials, timeouts) and state (cached tokens, expiry tracking). You will define all of this in a single Client struct.
- In VS Code, create a new file called client.go inside the sdk/ folder.
- Add the following code to define the Client struct, option functions, and constructor:
package sdk
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
// Client holds connection details and cached authentication state
type Client struct {
baseURL string
httpClient *http.Client
username string
password string
// Mutex protects the cached token from concurrent access
mu sync.Mutex
cachedToken string
expiresAt time.Time
// Retry configuration
maxRetries int
baseBackoff time.Duration
}
// ClientOption is a function that configures the client
type ClientOption func(*Client)
// WithTimeout sets the HTTP client timeout
func WithTimeout(timeout time.Duration) ClientOption {
return func(c *Client) {
c.httpClient.Timeout = timeout
}
}
// WithRetries configures retry attempts and backoff interval
func WithRetries(maxRetries int, baseBackoff time.Duration) ClientOption {
return func(c *Client) {
c.maxRetries = maxRetries
c.baseBackoff = baseBackoff
}
}
// NewClient creates a configured SDK client with sensible defaults
func NewClient(baseURL, username, password string, opts ...ClientOption) *Client {
c := &Client{
baseURL: baseURL,
username: username,
password: password,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
maxRetries: 3,
baseBackoff: 500 * time.Millisecond,
}
// Apply any custom options the caller passed in
for _, opt := range opts {
opt(c)
}
return c
}
What does this code do?
The Client struct groups everything the SDK needs: where to connect, how to authenticate, and the cached token state.
The ClientOption pattern (also called functional options) lets callers customize behavior without changing the constructor signature. NewClient sets safe defaults first, then applies any overrides. This is the standard pattern used in production Go SDKs like the AWS SDK and Google Cloud client libraries.
Why use a Mutex?
Multiple parts of a program (goroutines) might call Authenticate() at the same time. Without a sync.Mutex, two goroutines could both see an expired cache and fire duplicate network requests. The mutex ensures only one goroutine reads or writes the token at a time.
Implement authentication with token caching
The Authenticate() method is the SDK's main entry point. It checks the cache first and only makes a network request when the cached token is missing or expired.
The network layer also needs error types to distinguish between failures that are safe to retry (network issues, server errors) and failures that should stop immediately (bad credentials). You will create these first.
- Create a new file called errors.go inside the sdk/ folder.
- Add the following error type definitions:
package sdk
import "fmt"
// AuthError represents invalid credentials (non-retryable)
type AuthError struct {
StatusCode int
Message string
}
func (e *AuthError) Error() string {
return fmt.Sprintf("authentication failed (status %d): %s", e.StatusCode, e.Message)
}
// NetworkError represents connection failures (retryable)
type NetworkError struct {
Err error
Attempt int
}
func (e *NetworkError) Error() string {
return fmt.Sprintf("network error on attempt %d: %v", e.Attempt, e.Err)
}
func (e *NetworkError) Unwrap() error {
return e.Err
}
// ServerError represents 5xx responses (retryable)
type ServerError struct {
StatusCode int
Message string
Attempt int
}
func (e *ServerError) Error() string {
return fmt.Sprintf("server error (status %d) on attempt %d: %s", e.StatusCode, e.Attempt, e.Message)
}
What does this code do?
Each error type implements Go's error interface (which only requires an Error() string method). By having three distinct types, the SDK's retry logic can decide whether to retry or stop based on the error's type.
- AuthError: Wrong credentials. Retrying won't help.
- NetworkError: The server is unreachable. Worth retrying.
- ServerError: The server returned a 5xx. Worth retrying.
- Save sdk/errors.go.
Now add the Authenticate() method and its helper requestToken() to sdk/client.go.
- Return to sdk/client.go and add the following code below the NewClient function:
// tokenResponse maps the JSON response from the token server
type tokenResponse struct {
Token string `json:"token"`
ExpiresIn int `json:"expires_in"`
}
// Authenticate returns a valid token, using the cache when possible
func (c *Client) Authenticate() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
// Return cached token if still valid
if c.cachedToken != "" && time.Now().Before(c.expiresAt) {
return c.cachedToken, nil
}
// Cache miss - request a fresh token
token, expiresIn, err := c.requestToken()
if err != nil {
return "", err
}
// Store in cache for future calls
c.cachedToken = token
c.expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
return token, nil
}
How does the cache work?
The first if statement is the cache check. If cachedToken is not empty AND the current time is before expiresAt, the method returns immediately without touching the network. This is what makes the SDK fast for repeated calls.
The defer c.mu.Unlock() pattern guarantees the mutex releases even if the function returns early or panics. This prevents deadlocks.
- Still in sdk/client.go, add the requestToken() method below Authenticate():
// requestToken makes the HTTP request with retry logic
func (c *Client) requestToken() (string, int, error) {
body, _ := json.Marshal(map[string]string{
"username": c.username,
"password": c.password,
})
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff: 500ms, 1s, 2s...
backoff := c.baseBackoff * time.Duration(1<<(attempt-1))
time.Sleep(backoff)
}
resp, err := c.httpClient.Post(
c.baseURL+"/token",
"application/json",
bytes.NewReader(body),
)
if err != nil {
lastErr = &NetworkError{Err: err, Attempt: attempt + 1}
continue
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = &NetworkError{Err: err, Attempt: attempt + 1}
continue
}
// 401 = bad credentials, don't retry
if resp.StatusCode == http.StatusUnauthorized {
return "", 0, &AuthError{
StatusCode: resp.StatusCode,
Message: "invalid credentials",
}
}
// 5xx = server problem, retry
if resp.StatusCode >= 500 {
lastErr = &ServerError{
StatusCode: resp.StatusCode,
Message: string(respBody),
Attempt: attempt + 1,
}
continue
}
if resp.StatusCode != http.StatusOK {
return "", 0, &AuthError{
StatusCode: resp.StatusCode,
Message: string(respBody),
}
}
var tokenResp tokenResponse
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
return "", 0, fmt.Errorf("failed to parse token response: %w", err)
}
return tokenResp.Token, tokenResp.ExpiresIn, nil
}
return "", 0, fmt.Errorf("request failed after %d attempts: %w", c.maxRetries+1, lastErr)
}
What does requestToken do?
This method handles the actual network call with built-in resilience. The for loop retries up to maxRetries times, doubling the wait between each attempt (exponential backoff). With defaults of 500ms base and 3 retries, the delays are 500ms, 1s, 2s.
The key insight is selective retrying. Network errors and server 5xx responses trigger a retry (they might work next time). A 401 unauthorized stops immediately because retrying bad credentials is pointless.
- Save sdk/client.go.
Seeing red squiggles in VS Code?
If VS Code shows errors about undefined types, make sure you saved both sdk/errors.go and sdk/client.go. Go compiles all files in a package together, so both files need to exist for the types to resolve.
Help me fix undefined type errors in my Go SDK package.
Add helper methods and test the cache
Two small helpers let callers inspect the client's state without triggering a network request. These are useful for logging and debugging.
- Add the following methods at the bottom of sdk/client.go:
// Token returns the current cached token (empty string if none)
func (c *Client) Token() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.cachedToken
}
// IsTokenValid checks if the cached token is still within its expiry window
func (c *Client) IsTokenValid() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.cachedToken != "" && time.Now().Before(c.expiresAt)
}
What do these helpers do?
Token() returns whatever is in the cache without making a network call. IsTokenValid() checks both that a token exists and that it has not expired. Both lock the mutex for thread safety, even for reads.
- Save sdk/client.go.
✔️ Awesome, I've got everything!
Great. Double check you have saved both sdk/client.go and sdk/errors.go.
ⓧ I'd like to double check the full code
Here is the complete sdk/client.go:
package sdk
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
type Client struct {
baseURL string
httpClient *http.Client
username string
password string
mu sync.Mutex
cachedToken string
expiresAt time.Time
maxRetries int
baseBackoff time.Duration
}
type ClientOption func(*Client)
func WithTimeout(timeout time.Duration) ClientOption {
return func(c *Client) {
c.httpClient.Timeout = timeout
}
}
func WithRetries(maxRetries int, baseBackoff time.Duration) ClientOption {
return func(c *Client) {
c.maxRetries = maxRetries
c.baseBackoff = baseBackoff
}
}
func NewClient(baseURL, username, password string, opts ...ClientOption) *Client {
c := &Client{
baseURL: baseURL,
username: username,
password: password,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
maxRetries: 3,
baseBackoff: 500 * time.Millisecond,
}
for _, opt := range opts {
opt(c)
}
return c
}
type tokenResponse struct {
Token string `json:"token"`
ExpiresIn int `json:"expires_in"`
}
func (c *Client) Authenticate() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.cachedToken != "" && time.Now().Before(c.expiresAt) {
return c.cachedToken, nil
}
token, expiresIn, err := c.requestToken()
if err != nil {
return "", err
}
c.cachedToken = token
c.expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
return token, nil
}
func (c *Client) requestToken() (string, int, error) {
body, _ := json.Marshal(map[string]string{
"username": c.username,
"password": c.password,
})
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
backoff := c.baseBackoff * time.Duration(1<<(attempt-1))
time.Sleep(backoff)
}
resp, err := c.httpClient.Post(
c.baseURL+"/token",
"application/json",
bytes.NewReader(body),
)
if err != nil {
lastErr = &NetworkError{Err: err, Attempt: attempt + 1}
continue
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = &NetworkError{Err: err, Attempt: attempt + 1}
continue
}
if resp.StatusCode == http.StatusUnauthorized {
return "", 0, &AuthError{
StatusCode: resp.StatusCode,
Message: "invalid credentials",
}
}
if resp.StatusCode >= 500 {
lastErr = &ServerError{
StatusCode: resp.StatusCode,
Message: string(respBody),
Attempt: attempt + 1,
}
continue
}
if resp.StatusCode != http.StatusOK {
return "", 0, &AuthError{
StatusCode: resp.StatusCode,
Message: string(respBody),
}
}
var tokenResp tokenResponse
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
return "", 0, fmt.Errorf("failed to parse token response: %w", err)
}
return tokenResp.Token, tokenResp.ExpiresIn, nil
}
return "", 0, fmt.Errorf("request failed after %d attempts: %w", c.maxRetries+1, lastErr)
}
func (c *Client) Token() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.cachedToken
}
func (c *Client) IsTokenValid() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.cachedToken != "" && time.Now().Before(c.expiresAt)
}
And here is the complete sdk/errors.go:
package sdk
import "fmt"
type AuthError struct {
StatusCode int
Message string
}
func (e *AuthError) Error() string {
return fmt.Sprintf("authentication failed (status %d): %s", e.StatusCode, e.Message)
}
type NetworkError struct {
Err error
Attempt int
}
func (e *NetworkError) Error() string {
return fmt.Sprintf("network error on attempt %d: %v", e.Attempt, e.Err)
}
func (e *NetworkError) Unwrap() error {
return e.Err
}
type ServerError struct {
StatusCode int
Message string
Attempt int
}
func (e *ServerError) Error() string {
return fmt.Sprintf("server error (status %d) on attempt %d: %s", e.StatusCode, e.Attempt, e.Message)
}
Now create an example program that authenticates twice and proves the second call uses the cache.
- Create a new file called main.go inside the example/ folder.
- Add the following code to exercise the SDK:
package main
import (
"fmt"
"time"
"authsdk/sdk"
)
func main() {
// Create a client pointing at our local token server
client := sdk.NewClient(
"http://localhost:9090",
"alice",
"password123",
sdk.WithTimeout(5*time.Second),
)
// First call hits the network
fmt.Println("1. Authenticating...")
token, err := client.Authenticate()
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
fmt.Printf(" Token received: %s...\n", token[:20])
// Second call should return the cached token
fmt.Println("2. Authenticating again (should use cache)...")
token2, err := client.Authenticate()
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
if token == token2 {
fmt.Println(" Cache hit! Same token returned.")
}
}
What does this example prove?
If both calls return the exact same token string, the second call did not hit the network. It pulled the token from the in-memory cache. In a production system, this avoids unnecessary latency and load on the auth server.
- Save example/main.go.
- Make sure the token server is still running in its terminal from the previous step.
- Open a new terminal and run the example by running this command:
go run ./example
- You should see output confirming both a token was received and the cache was hit:
1. Authenticating...
Token received: eyJ1c2VybmFtZSI6MDI...
2. Authenticating again (should use cache)...
Cache hit! Same token returned.
What should I see?
The exact token string in your output will differ (it contains a timestamp). The important thing is that line 2 prints "Cache hit!" confirming the SDK did not make a second network request.
Not seeing the cache hit message?
Make sure the token server is running in another terminal (go run ./server). If you see a connection refused error, the server is not running or is on a different port.
If you get import errors mentioning authsdk/sdk, confirm your go.mod file has module authsdk as the module name. The import path must match the module name exactly.
Help me debug my Go SDK example that won't run.
Your SDK can authenticate and cache tokens. Next up, you will explore the error handling and retry logic in depth by testing what happens when things go wrong.
Add Retry Logic and Error Handling
Your SDK can authenticate and cache tokens. But what happens when the network drops, the server is overloaded, or the credentials are wrong?
Production SDKs need to distinguish between errors that are worth retrying (network blips, server overloads) and errors that should fail immediately (bad passwords). In this step, you'll define custom error types and verify the retry logic that decides how to handle each one.
In this step, get ready to:
- Create three custom error types that classify failures as retryable or non-retryable.
- Verify the SDK's retry loop compiles with exponential backoff.
- Test error handling and retry behavior in the example client.
Define custom error types
Your sdk/client.go already references three error types in its retry loop. Right now, those types don't exist yet, so the code won't compile. You'll fix that by creating a dedicated errors file.
- Create a new file called errors.go inside your sdk folder.
- Add the AuthError type by pasting this code:
package sdk
import "fmt"
// AuthError represents a non-retryable authentication failure
// (e.g., invalid credentials)
type AuthError struct {
StatusCode int
Message string
}
func (e *AuthError) Error() string {
return fmt.Sprintf("authentication failed (status %d): %s", e.StatusCode, e.Message)
}
What does this code do?
AuthError represents a permanent failure like invalid credentials. The retry loop sees this type and stops immediately because retrying won't fix a wrong password.
The Error() method satisfies Go's built-in error interface, which only requires one method: a function that returns a string.
- Add the NetworkError type below AuthError:
// NetworkError represents a retryable connection failure
type NetworkError struct {
Err error
Attempt int
}
func (e *NetworkError) Error() string {
return fmt.Sprintf("network error on attempt %d: %v", e.Attempt, e.Err)
}
func (e *NetworkError) Unwrap() error {
return e.Err
}
What does this code do?
NetworkError wraps the original connection error and tracks which retry attempt it occurred on. The retry loop sees this type and tries again.
The Unwrap() method lets Go's errors.As() and errors.Is() functions dig into the wrapped error for deeper inspection.
- Add the ServerError type below NetworkError:
// ServerError represents a retryable server-side failure (5xx)
type ServerError struct {
StatusCode int
Message string
Attempt int
}
func (e *ServerError) Error() string {
return fmt.Sprintf("server error (status %d) on attempt %d: %s", e.StatusCode, e.Attempt, e.Message)
}
What does this code do?
ServerError captures 5xx HTTP responses. These are temporary server-side failures that often resolve on retry. Like NetworkError, the retry loop will try again when it encounters this type.
- Save sdk/errors.go.
Seeing red squiggles in VS Code?
Make sure the first line of sdk/errors.go says package sdk (not package main). All files in the sdk/ folder must share the same package name.
help me fix package errors in my Go SDK
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
package sdk
import "fmt"
// AuthError represents a non-retryable authentication failure
// (e.g., invalid credentials)
type AuthError struct {
StatusCode int
Message string
}
func (e *AuthError) Error() string {
return fmt.Sprintf("authentication failed (status %d): %s", e.StatusCode, e.Message)
}
// NetworkError represents a retryable connection failure
type NetworkError struct {
Err error
Attempt int
}
func (e *NetworkError) Error() string {
return fmt.Sprintf("network error on attempt %d: %v", e.Attempt, e.Err)
}
func (e *NetworkError) Unwrap() error {
return e.Err
}
// ServerError represents a retryable server-side failure (5xx)
type ServerError struct {
StatusCode int
Message string
Attempt int
}
func (e *ServerError) Error() string {
return fmt.Sprintf("server error (status %d) on attempt %d: %s", e.StatusCode, e.Attempt, e.Message)
}
Verify the retry logic compiles
Your sdk/client.go already contains a retry loop in requestToken() that references these three error types. Now that errors.go exists, the SDK package should compile successfully.
- Verify the SDK compiles by running this command in your terminal (from the authsdk folder):
go build ./sdk
- If the command returns with no output, it compiled successfully.
How does the retry loop use these error types?
The requestToken() method in client.go uses your error types to decide what to do after a failure:
- NetworkError (connection refused, timeout): stores the error in lastErr and continues to the next attempt.
- ServerError (5xx status codes): stores the error and continues. The server might recover.
- AuthError (401 Unauthorized): returns immediately with no retry. A wrong password won't magically become right.
Between retries, the backoff doubles each time: 500ms, 1s, 2s. This is exponential backoff. It gives overloaded servers breathing room to recover.
Why this approach?
You might expect all errors to be handled the same way. But production SDKs (like the AWS SDK and Google Cloud client libraries) classify errors into retryable vs non-retryable categories.
Retrying a bad password wastes time and can trigger rate limits. Giving up after one network hiccup frustrates users. Custom error types let your SDK make smart decisions automatically.
Seeing a compile error?
Check that sdk/errors.go has all three types (AuthError, NetworkError, ServerError) and that each has an Error() string method. The client.go file references all three by name.
help me fix Go compile errors in my SDK package
Test error handling and retry behavior
The error types work at compile time. Now you need to see them in action at runtime. You'll update the example client to trigger both an AuthError (wrong password) and a NetworkError (unavailable server).
- Replace the entire contents of example/main.go with this code:
package main
import (
"errors"
"fmt"
"time"
"authsdk/sdk"
)
func main() {
// Create a client with retry configuration
client := sdk.NewClient(
"http://localhost:9090",
"alice",
"password123",
sdk.WithTimeout(5*time.Second),
sdk.WithRetries(3, 500*time.Millisecond),
)
fmt.Println("1. Authenticating...")
token, err := client.Authenticate()
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
fmt.Printf(" Token received: %s...\n", token[:20])
// Test non-retryable error: wrong password
fmt.Println("2. Testing with invalid credentials...")
badClient := sdk.NewClient("http://localhost:9090", "alice", "wrongpassword")
_, err = badClient.Authenticate()
if err != nil {
var authErr *sdk.AuthError
if errors.As(err, &authErr) {
fmt.Printf(" Correctly caught AuthError: %s\n", authErr.Message)
}
}
// Test retryable error: server not running on this port
fmt.Println("3. Testing retry logic (unavailable server)...")
retryClient := sdk.NewClient(
"http://localhost:9999",
"alice",
"password123",
sdk.WithRetries(2, 200*time.Millisecond),
)
start := time.Now()
_, err = retryClient.Authenticate()
elapsed := time.Since(start)
if err != nil {
fmt.Printf(" Failed after retries (took %v): %v\n", elapsed.Round(time.Millisecond), err)
}
}
What does this code do?
- Test 1 authenticates normally to confirm the SDK still works with WithRetries configured.
- Test 2 sends a wrong password. The SDK detects a 401 response, wraps it in an AuthError, and returns immediately with no retries.
- Test 3 connects to port 9999 where nothing is running. The SDK retries 2 times with exponential backoff, then gives up. You can observe the elapsed time to confirm the delays happened.
- errors.As(err, &authErr) checks whether err (or any error it wraps) matches the AuthError type. This is Go's idiomatic way to inspect structured errors.
- Save example/main.go.
- Make sure your token server is still running in its terminal. If it stopped, restart it by running:
go run ./server
- In a separate terminal, run the example client:
go run ./example
- You should see three tests run. Test 1 receives a token. Test 2 catches an AuthError instantly. Test 3 takes a noticeable pause (the retries) before reporting failure.
Test 1 failing with a connection error?
Your token server must be running on port 9090 in another terminal. If you closed it, restart it with go run ./server and then re-run the example.
help me debug my Go example client connecting to the token server
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
package main
import (
"errors"
"fmt"
"time"
"authsdk/sdk"
)
func main() {
client := sdk.NewClient(
"http://localhost:9090",
"alice",
"password123",
sdk.WithTimeout(5*time.Second),
sdk.WithRetries(3, 500*time.Millisecond),
)
fmt.Println("1. Authenticating...")
token, err := client.Authenticate()
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
fmt.Printf(" Token received: %s...\n", token[:20])
fmt.Println("2. Testing with invalid credentials...")
badClient := sdk.NewClient("http://localhost:9090", "alice", "wrongpassword")
_, err = badClient.Authenticate()
if err != nil {
var authErr *sdk.AuthError
if errors.As(err, &authErr) {
fmt.Printf(" Correctly caught AuthError: %s\n", authErr.Message)
}
}
fmt.Println("3. Testing retry logic (unavailable server)...")
retryClient := sdk.NewClient(
"http://localhost:9999",
"alice",
"password123",
sdk.WithRetries(2, 200*time.Millisecond),
)
start := time.Now()
_, err = retryClient.Authenticate()
elapsed := time.Since(start)
if err != nil {
fmt.Printf(" Failed after retries (took %v): %v\n", elapsed.Round(time.Millisecond), err)
}
}
Your SDK now handles failures like a production library. It retries transient errors with exponential backoff, fails fast on permanent errors, and gives callers structured error types to inspect. Next up, you'll build auth middleware that protects API routes using the tokens your SDK issues.
Build Auth Middleware and Test End-to-End
Your SDK can authenticate, cache tokens, retry on failure, and handle errors cleanly. Now it's time to put it all together into a real-world security flow.
In this step, you'll build an auth middleware that protects API endpoints using Bearer tokens. Any request without a valid token gets rejected before it reaches your application logic. This is the same pattern that enterprise security frameworks use to guard thousands of internal APIs.
In this step, get ready to:
- Create auth middleware that validates Bearer tokens against the token server.
- Build a protected API server with public and private endpoints.
- Run all three components together and verify the full authentication chain end-to-end.
Create the auth middleware
Middleware sits between an incoming request and your application handler. It inspects the request, decides whether to allow or reject it, and only passes valid requests through to the next handler.
- Create a new file called sdk/middleware.go in VS Code.
- Add the following code to define the AuthMiddleware function:
package sdk
import (
"encoding/json"
"net/http"
"strings"
)
func AuthMiddleware(tokenServerURL string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract the Authorization header from the incoming request
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"missing_token","message":"Authorization header required"}`))
return
}
// Strip the "Bearer " prefix to get the raw token
token := strings.TrimPrefix(authHeader, "Bearer ")
// Build a validation request to the token server
req, err := http.NewRequest(http.MethodGet, tokenServerURL+"/validate", nil)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"internal_error","message":"failed to create validation request"}`))
return
}
req.Header.Set("Authorization", "Bearer "+token)
// Send the token to the token server for verification
resp, err := http.DefaultClient.Do(req)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"validation_failed","message":"could not reach token server"}`))
return
}
defer resp.Body.Close()
// Reject the request if the token server says the token is invalid
if resp.StatusCode != http.StatusOK {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"invalid_token","message":"token validation failed"}`))
return
}
// Parse the validated username and pass it downstream
var result map[string]string
json.NewDecoder(resp.Body).Decode(&result)
r.Header.Set("X-Authenticated-User", result["username"])
next.ServeHTTP(w, r)
})
}
}
What does this code do?
AuthMiddleware returns a function that wraps any http.Handler. This is Go's standard middleware pattern. Every request passes through this wrapper before reaching your actual handler.
- It extracts the Bearer token from the Authorization header.
- It sends the token to the token server's /validate endpoint for verification.
- If valid, it sets the username in an X-Authenticated-User header so downstream handlers know who made the request.
- If invalid or missing, the request is rejected with a 401 before it ever reaches the handler.
- Save sdk/middleware.go.
You won't see a change yet. The middleware needs a server to mount it on, which you'll create next.
Seeing a red underline in VS Code?
If VS Code shows errors in middleware.go, make sure the file's first line is package sdk (matching the other files in the sdk/ folder). Also confirm that you saved sdk/errors.go and sdk/client.go from the previous steps.
Help me debug my middleware.go file
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
package sdk
import (
"encoding/json"
"net/http"
"strings"
)
func AuthMiddleware(tokenServerURL string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"missing_token","message":"Authorization header required"}`))
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
req, err := http.NewRequest(http.MethodGet, tokenServerURL+"/validate", nil)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"internal_error","message":"failed to create validation request"}`))
return
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"validation_failed","message":"could not reach token server"}`))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"invalid_token","message":"token validation failed"}`))
return
}
var result map[string]string
json.NewDecoder(resp.Body).Decode(&result)
r.Header.Set("X-Authenticated-User", result["username"])
next.ServeHTTP(w, r)
})
}
}
Create the protected API server
Now you need a server that uses the middleware. This server has two endpoints: one public (anyone can access) and one protected (requires a valid token).
- Create a new file called protected/main.go in VS Code.
- Add the following code to set up the API server with both endpoints:
package main
import (
"fmt"
"log"
"net/http"
"authsdk/sdk"
)
func main() {
mux := http.NewServeMux()
// Define the handler for the protected endpoint
protectedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username := r.Header.Get("X-Authenticated-User")
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"message":"Welcome, %s! You have access to the protected resource."}`, username)
})
// Wrap the protected handler with the auth middleware
mux.Handle("/secret", sdk.AuthMiddleware("http://localhost:9090")(protectedHandler))
// A public endpoint that requires no authentication
mux.HandleFunc("/public", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message":"This is public, no token needed."}`))
})
fmt.Println("Protected API running on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
What does this code do?
- sdk.AuthMiddleware("http://localhost:9090")(protectedHandler) wraps the secret handler with your middleware, pointing it at the token server for validation.
- GET /secret requires a valid Bearer token. If the token passes validation, the handler reads the username from X-Authenticated-User and greets the user.
- GET /public is open to everyone with no authentication required.
- Save protected/main.go.
- Open a second terminal in VS Code by pressing Ctrl+Shift+`.
- Start the protected API server by running this command:
go run ./protected
You should see Protected API running on http://localhost:8080 printed in the terminal.
Seeing an error about port 8080?
If you see a "bind: address already in use" error, another program is using port 8080. Close it or check if you accidentally started the protected server twice. Also make sure your token server is still running in the first terminal on port 9090.
Help me fix my protected server startup error
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
package main
import (
"fmt"
"log"
"net/http"
"authsdk/sdk"
)
func main() {
mux := http.NewServeMux()
protectedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username := r.Header.Get("X-Authenticated-User")
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"message":"Welcome, %s! You have access to the protected resource."}`, username)
})
mux.Handle("/secret", sdk.AuthMiddleware("http://localhost:9090")(protectedHandler))
mux.HandleFunc("/public", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message":"This is public, no token needed."}`))
})
fmt.Println("Protected API running on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Update the example client and run end-to-end
The final piece is updating your example client to exercise the full authentication chain. It will authenticate with the SDK, use the token to access the protected endpoint, and demonstrate what happens when the token is missing or invalid.
- Switch back to example/main.go in VS Code.
- Replace the entire file contents with the following full end-to-end demo:
package main
import (
"errors"
"fmt"
"io"
"net/http"
"time"
"authsdk/sdk"
)
func main() {
client := sdk.NewClient(
"http://localhost:9090",
"alice",
"password123",
sdk.WithTimeout(5*time.Second),
sdk.WithRetries(3, 500*time.Millisecond),
)
fmt.Println("=== Auth SDK Demo ===")
fmt.Println()
// Step 1: Authenticate and get a token
fmt.Println("1. Authenticating with the token server...")
token, err := client.Authenticate()
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
fmt.Printf(" Token received: %s...\n", token[:20])
fmt.Println()
// Step 2: Prove token caching works
fmt.Println("2. Calling Authenticate() again (should use cache)...")
token2, err := client.Authenticate()
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
if token == token2 {
fmt.Println(" Cache hit! Same token returned without a network request.")
}
fmt.Println()
// Step 3: Use the token to access the protected endpoint
fmt.Println("3. Accessing protected resource with token...")
req, _ := http.NewRequest(http.MethodGet, "http://localhost:8080/secret", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf(" Response (%d): %s\n", resp.StatusCode, string(body))
fmt.Println()
// Step 4: Try without a token to see the middleware reject it
fmt.Println("4. Testing without a token (should be rejected)...")
req2, _ := http.NewRequest(http.MethodGet, "http://localhost:8080/secret", nil)
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
body2, _ := io.ReadAll(resp2.Body)
resp2.Body.Close()
fmt.Printf(" Response (%d): %s\n", resp2.StatusCode, string(body2))
fmt.Println()
// Step 5: Test invalid credentials (AuthError)
fmt.Println("5. Testing with invalid credentials...")
badClient := sdk.NewClient(
"http://localhost:9090",
"alice",
"wrongpassword",
)
_, err = badClient.Authenticate()
if err != nil {
var authErr *sdk.AuthError
if errors.As(err, &authErr) {
fmt.Printf(" Correctly caught AuthError: %s\n", authErr.Message)
}
}
fmt.Println()
// Step 6: Test retry logic against an unavailable server
fmt.Println("6. Testing retry logic (unavailable server)...")
retryClient := sdk.NewClient(
"http://localhost:9999",
"alice",
"password123",
sdk.WithRetries(2, 200*time.Millisecond),
)
start := time.Now()
_, err = retryClient.Authenticate()
elapsed := time.Since(start)
if err != nil {
fmt.Printf(" Failed after retries (took %v): %v\n", elapsed.Round(time.Millisecond), err)
}
fmt.Println()
fmt.Println("=== Demo Complete ===")
}
What does this demo cover?
This file exercises every feature of your SDK in sequence:
- Steps 1-2 prove authentication and token caching work.
- Step 3 calls the protected /secret endpoint with a valid token and gets a 200 response.
- Step 4 calls /secret without a token and gets a 401 rejection from the middleware.
- Steps 5-6 test error handling (invalid credentials) and retry logic (unavailable server).
- Save example/main.go.
- Make sure the token server is still running in your first terminal (you should see Token server running on http://localhost:9090).
- Make sure the protected API is still running in your second terminal (you should see Protected API running on http://localhost:8080).
- Open a third terminal in VS Code by pressing Ctrl+Shift+`.
- Run the full end-to-end demo by running this command:
go run ./example
You should see output like this showing all six tests passing:
=== Auth SDK Demo ===
1. Authenticating with the token server...
Token received: eyJ1c2VybmFtZSI6MDJ...
2. Calling Authenticate() again (should use cache)...
Cache hit! Same token returned without a network request.
3. Accessing protected resource with token...
Response (200): {"message":"Welcome, alice! You have access to the protected resource."}
4. Testing without a token (should be rejected)...
Response (401): {"error":"missing_token","message":"Authorization header required"}
5. Testing with invalid credentials...
Correctly caught AuthError: invalid credentials
6. Testing retry logic (unavailable server)...
Failed after retries (took 600ms): request failed after 3 attempts: ...
=== Demo Complete ===
What should I see?
The key results to look for: Step 3 returns a 200 with a welcome message (the middleware allowed it). Step 4 returns a 401 (the middleware blocked it). Your token values and retry timing may differ slightly, but the status codes and structure should match.
Seeing connection refused errors in Step 3 or 4?
This means the protected API server on port 8080 is not running. Check your second terminal. If it crashed or was accidentally closed, restart it with go run ./protected.
If you see a compilation error, make sure you saved both sdk/middleware.go and protected/main.go before running.
Help me debug my end-to-end test
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
package main
import (
"errors"
"fmt"
"io"
"net/http"
"time"
"authsdk/sdk"
)
func main() {
client := sdk.NewClient(
"http://localhost:9090",
"alice",
"password123",
sdk.WithTimeout(5*time.Second),
sdk.WithRetries(3, 500*time.Millisecond),
)
fmt.Println("=== Auth SDK Demo ===")
fmt.Println()
fmt.Println("1. Authenticating with the token server...")
token, err := client.Authenticate()
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
fmt.Printf(" Token received: %s...\n", token[:20])
fmt.Println()
fmt.Println("2. Calling Authenticate() again (should use cache)...")
token2, err := client.Authenticate()
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
if token == token2 {
fmt.Println(" Cache hit! Same token returned without a network request.")
}
fmt.Println()
fmt.Println("3. Accessing protected resource with token...")
req, _ := http.NewRequest(http.MethodGet, "http://localhost:8080/secret", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf(" Response (%d): %s\n", resp.StatusCode, string(body))
fmt.Println()
fmt.Println("4. Testing without a token (should be rejected)...")
req2, _ := http.NewRequest(http.MethodGet, "http://localhost:8080/secret", nil)
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
body2, _ := io.ReadAll(resp2.Body)
resp2.Body.Close()
fmt.Printf(" Response (%d): %s\n", resp2.StatusCode, string(body2))
fmt.Println()
fmt.Println("5. Testing with invalid credentials...")
badClient := sdk.NewClient(
"http://localhost:9090",
"alice",
"wrongpassword",
)
_, err = badClient.Authenticate()
if err != nil {
var authErr *sdk.AuthError
if errors.As(err, &authErr) {
fmt.Printf(" Correctly caught AuthError: %s\n", authErr.Message)
}
}
fmt.Println()
fmt.Println("6. Testing retry logic (unavailable server)...")
retryClient := sdk.NewClient(
"http://localhost:9999",
"alice",
"password123",
sdk.WithRetries(2, 200*time.Millisecond),
)
start := time.Now()
_, err = retryClient.Authenticate()
elapsed := time.Since(start)
if err != nil {
fmt.Printf(" Failed after retries (took %v): %v\n", elapsed.Round(time.Millisecond), err)
}
fmt.Println()
fmt.Println("=== Demo Complete ===")
}
Why this approach matters
You now have three cooperating services: a token issuer, a protected API, and a client SDK. This is exactly how enterprise authentication works at scale. The middleware pattern lets any API protect its endpoints just by wrapping handlers, without knowing anything about token cryptography.
Secret mission
Add Automatic Token Refresh
Your SDK caches tokens, but what happens when they expire mid-session? Production SDKs solve this with background refresh. Add a goroutine that automatically re-authenticates before your token expires, so the client never holds a stale credential.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing cloud costs. Decide whether to keep your resources for future reference, pause the running servers, or delete everything entirely.
Resources you used:
- Token server running on port 9090.
- Protected API server running on port 8080.
- The authsdk project folder on your Desktop.
- Go 1.26.4 installed via the Windows MSI installer.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the SDK.
- Your servers will continue running until you close the terminal windows or press Ctrl+C.
- The authsdk folder stays on your Desktop for future reference.
- Go remains installed and available for other projects.
Pause - I'll come back to this later
Stop the running servers to free up your terminal sessions. Your project files stay on disk so you can restart anytime.
- In each terminal window running a server, press Ctrl+C to stop the process.
- When you want to restart, open a terminal in your authsdk folder and run:
go run ./server
- In a second terminal, restart the protected API:
go run ./protected
Delete - I don't want to use this again
Remove all project files and optionally uninstall Go from your system.
- In each terminal window running a server, press Ctrl+C to stop the process.
- Delete the project folder by running this command in a new terminal:
macOS
rm -rf ~/Desktop/authsdk
Windows
Remove-Item -Recurse -Force ~\Desktop\authsdk
- To uninstall Go, open Settings on your Windows machine.
- Select Apps, then Apps & features.
- Search for Go Programming Language, select it, and click Uninstall.
Nice Work!
Nice Work!
You just built a production-grade authentication SDK in Go from scratch. You now have a working token server, a reusable client library, and auth middleware protecting your API routes.
You've learned how to:
- Built a token server that issues and validates HMAC-signed authentication tokens using Go's standard library.
- Created a reusable SDK client library with automatic token caching and thread-safe access.
- Implemented retry logic with exponential backoff and three custom error types (AuthError, NetworkError, ServerError).
- Built auth middleware that protects API routes using the Bearer token pattern.
- Secret Mission: Added automatic background token refresh using Go goroutines and channels.
Ready to quiz yourself?