Minimal OAuth 2.0 Server Setup

Set up a minimal OAuth 2.0 server — hands-on steps, troubleshooting, and what to study next.

Focus: set up a minimal oauth 2.0 server

Sponsored

You’ve heard the hype: OAuth 2.0 is the backbone of modern authorization, but the spec is dense, and production frameworks like Keycloak or Auth0 hide the moving parts behind heavy configuration. When you actually need to set up a minimal OAuth 2.0 server—for a demo, a test suite, or to truly understand the flow—you’re left with a maze of RFCs, endpoints, and token types. This lesson cuts through that fog. You’ll build a working server with the Python authlib library in under fifty lines, see exactly how the authorization code flow works from the inside, and walk away with the mental model to reason about any OAuth implementation, whether it’s a tiny microservice or an enterprise IdP.

The problem this lesson solves

Most developers first encounter OAuth as a client—redirect to a login page, get a token back, and glue it to an API. But what happens when you are the identity provider? Maybe you need a local OAuth server for integration testing, a quick prototype for a partner demo, or you want to debug why your redirect_uri mismatch is driving you mad. Official OAuth servers are heavyweight: Keycloak is a full Java application with themes and admin consoles, and Okta/Auth0 are cloud services with a monthly fee. Fiddling with those just to validate an idea is overkill. A minimal server built from scratch gives you:

  • Clarity — see every request and response in plain Python.
  • Control — add custom claims, scopes, or token lifetimes in minutes.
  • Speed — stand up a demo in one sitting, not one sprint.

Without this skill, you’re forever reliant on someone else’s implementation, and when OAuth quirks bite, you have no map. That’s the pain: OAuth is easy to use but hard to understand, and the only way to understand it is to build one.

Core concept / mental model

Think of OAuth 2.0 as a bank vault with a valet key. The vault (your protected resources) doesn’t trust your customers directly with the master key. Instead, a valet (the authorization server) hands out a limited-use key that grants access only to specific rooms (scopes) for a specific time (expiry). The master key never leaves the bank.

In OAuth terms:

  • Resource owner — the user who has the master key (the person clicking “Allow”).
  • Client — your app that wants to access the vault on behalf of the user.
  • Authorization server — the bank’s valet desk; it issues tokens.
  • Resource server — the vault itself; it validates tokens before letting you in.

The authorization code flow is the most common pattern (and the one you’ll implement). It’s like this:

  1. Client sends the user to the authorization server with a list of requested permissions (scope) and a callback address (redirect_uri).
  2. User logs in and approves.
  3. Authorization server redirects back to the client with a code — that’s the valet’s receipt, not the key itself. It’s short-lived and useless on its own.
  4. Client exchanges that code, along with its own credentials, at the token endpoint for an access token — the actual valet key.
  5. Client uses that token to access the resource server until it expires.

Pro tip: The whole point of the code exchange is that the user’s browser never sees the access token. The token is sent directly from the authorization server to the client in a back-channel request, so it stays out of the URL and browser history. That’s the security core of the flow.

How it works step by step

Now let’s peel back the cover and see the exact HTTP interactions that make this happen. You’ll implement these endpoints on your minimal server.

Step 1: The authorization endpoint

This is the human-facing door. The client sends the user here with query parameters:

  • response_type=code — tells the server you expect an authorization code.
  • client_id — identifies your client.
  • redirect_uri — where to send the user after approval (must match the registered one!).
  • scope — the permissions requested, e.g., profile email.
  • state — a random string to prevent CSRF (you’ll echo it back).

The server should validate these, show a login/consent screen (even a dummy one for minimal setup), and then redirect to redirect_uri?code=...&state=....

Step 2: The token endpoint

The client (never the browser) makes a direct POST here with grant_type=authorization_code, the code, redirect_uri, and its own client_id + client_secret. The server verifies the code, checks it hasn’t expired, and returns a JSON blob containing an access_token, token_type, expires_in, and optionally a refresh_token. This is the valet handing over the key.

Step 3: The resource server validation

The protected API receives the access token (usually in an Authorization: Bearer ... header) and must validate it. For a minimal server, you can just look up the token in a database; in production, you’d verify signatures or call an introspection endpoint. If the token is valid and not expired, grant access.

Here’s the sequence in plain words:

  1. User clicks “Login with demo” on your app.
  2. Browsers go to http://localhost:5000/authorize?....
  3. Server displays a consent page saying “App wants permission to read your email.”
  4. User clicks “Allow.”
  5. Browser is redirected to http://localhost:5001/callback?code=X&state=Y.
  6. Your app’s backend secretly POSTs to http://localhost:5000/token with code=X.
  7. Server responds with access_token=abc123.
  8. Your app calls GET /api/me with header Authorization: Bearer abc123.
  9. Resource server verifies the token and returns the user’s profile.

Each step is a precise contract. Break one param, and the whole flow fails—which brings us to the hands-on part.

Hands-on walkthrough

We’ll use Authlib (a mature Python library) to keep the code minimal but standards-compliant. Install it and Flask:

pip install flask authlib

1. Minimal OAuth server (server.py)

Create a file server.py with the core authorization and token endpoints. Authlib provides the tricky RFC control flow, but you supply validation logic. Here’s a full, runnable example:

from authlib.integrations.flask_oauth2 import AuthorizationServer, ResourceProtector
from authlib.oauth2.rfc6749 import grants
from flask import Flask, request, jsonify, redirect, render_template_string
from werkzeug.security import gen_salt
import os

app = Flask(__name__)
app.secret_key = os.urandom(24)

# In-memory storage for a demo. Use a real DB in production.
clients = {
    "demo-client": {
        "client_id": "demo-client",
        "client_secret": "demo-secret",
        "redirect_uris": ["http://localhost:5001/callback"],
        "token_endpoint_auth_method": "client_secret_basic",
        "grant_types": ["authorization_code"],
        "scope": "profile email",
    }
}
users = {"alice": {"password": "secret", "sub": "1", "email": "alice@example.com"}}
tokens = {}
auth_codes = {}

class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
    def create_authorization_code(self, client, grant_user, request):
        code = gen_salt(32)
        auth_codes[code] = {"client_id": client.client_id, "user_id": grant_user["sub"], "scope": request.scope}
        return code

    def parse_authorization_code(self, code, client):
        info = auth_codes.pop(code, None)
        if info and info["client_id"] == client.client_id:
            return ({}, info["scope"])
        return False

# Register the grant with the server
authorization = AuthorizationServer(app, query_client=lambda client_id: clients.get(client_id), save_token=lambda token, request: tokens.update({token["access_token"]: token}))
authorization.register_grant(AuthorizationCodeGrant)

@app.route("/authorize", methods=["GET", "POST"])
def authorize():
    # In a real app, you'd check if the user is logged in and show a consent screen.
    # For demo, we auto-approve and assume user=alice.
    if request.method == "GET":
        # Validate parameters and build the login/consent form
        client = authorization.get_authorization_grant_endpoint()
        return render_template_string('''<h1>Authorize {{ client.client_id }}?</h1>
            <form method="post">
                <button name="approve" value="yes">Allow</button>
                <button name="approve" value="no">Deny</button>
            </form>''', client=client)
    else:
        if request.form.get("approve") == "yes":
            # Simulate the logged-in user
            grant_user = users["alice"]
            return authorization.create_authorization_response(grant_user=grant_user)
        return authorization.create_authorization_response(grant_user=None)

@app.route("/token", methods=["POST"])
def issue_token():
    return authorization.create_token_response()

@app.route("/api/me")
def me():
    # Minimal resource server validation: just check the token exists
    auth = request.headers.get("Authorization", "")
    token = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
    if token and token in tokens:
        # In reality, you'd decode JWT or call introspection
        return jsonify({"user": "alice", "email": users["alice"]["email"]})
    return jsonify({"error": "invalid_token"}), 401

if __name__ == "__main__":
    app.run(port=5000, debug=True)

Save it and run python server.py. The server is now listening on port 5000 with /authorize and /token endpoints.

2. A test client (client.py)

Open another terminal and create client.py to simulate the flow without a browser. You’ll do the redirects manually to see every step:

import requests
import webbrowser
from urllib.parse import urlencode, urlparse, parse_qs

BASE = "http://localhost:5000"
REDIRECT_URI = "http://localhost:5001/callback"
CLIENT_ID = "demo-client"
CLIENT_SECRET = "demo-secret"

# Step 1: Build the authorization URL
params = {
    "response_type": "code",
    "client_id": CLIENT_ID,
    "redirect_uri": REDIRECT_URI,
    "scope": "profile email",
    "state": "xyz123"  # MUST be random in production
}
url = BASE + "/authorize?" + urlencode(params)
print("Opening authorization URL:", url)
# In a real scenario, the user would interact with the consent page.
# We simulate a POST approval directly:
resp = requests.post(url, data={"approve": "yes"}, allow_redirects=False)
if resp.status_code == 302:
    location = resp.headers["Location"]
    print("Redirect to:", location)
    query = parse_qs(urlparse(location).query)
    code = query["code"][0]
    state = query["state"][0]
    assert state == "xyz123", "State mismatch!"
else:
    print("Authorization failed:", resp.text)
    exit(1)

# Step 2: Exchange code for token
token_resp = requests.post(BASE + "/token", data={
    "grant_type": "authorization_code",
    "code": code,
    "redirect_uri": REDIRECT_URI,
    "client_id": CLIENT_ID,
    "client_secret": CLIENT_SECRET
})
token_data = token_resp.json()
print("Token response:", token_data)
access_token = token_data["access_token"]

# Step 3: Use the token to call the resource API
me = requests.get(BASE + "/api/me", headers={"Authorization": f"Bearer {access_token}"})
print("API response:", me.json())

Run it with python client.py. Expected output (abridged):

Opening authorization URL: http://localhost:5000/authorize?...
Redirect to: http://localhost:5001/callback?code=...&state=xyz123
Token response: {'access_token': '...', 'token_type': 'Bearer', 'expires_in': 86400, 'scope': 'profile email'}
API response: {'user': 'alice', 'email': 'alice@example.com'}

You just set up a minimal OAuth 2.0 server and walked through the full authorization code flow.

Pro tip: The state parameter is your firewall against CSRF. Always verify it matches on the callback—our client does. In a real app, store it in the session and compare.

Compare options / when to choose what

You have choices for building an OAuth server, and each fits different needs:

Approach Setup Effort Customization Production Readiness Best For
Custom minimal (like this) Low High Low (need to add HTTPS, persistence, more grants) Learning, demos, internal test harnesses
Authlib (with more config) Medium High Medium Production APIs that need OAuth without buying a cloud service
Keycloak / Spring Authorization Server High Medium High Enterprise SSO, multi-realm, complex policies
Auth0 / Okta / Cognito Very low (SaaS) Low (vendor lock-in) High Startups needing fast time-to-market

When to choose what:

  • Need to understand OAuth deeply or test client libraries? Build a custom one—it’s the best teacher.
  • Moving to production with a small team? Authlib gives you a solid foundation to grow.
  • Large organization with compliance requirements? Invest in Keycloak or a managed IdP.

The trade-off is between control and convenience. As a learner, control wins.

Troubleshooting & edge cases

OAuth failures are notorious for cryptic messages. Here are the common ones you’ll hit:

  • redirect_uri mismatch — The authorization server compares the exact string you give at /authorize with the one you registered. A trailing slash, a different port, or an HTTP vs HTTPS difference will cause a silent refusal. Fix: print both values and check character for character.
  • invalid_grant — The authorization code was already used, expired, or doesn’t match the client. Codes are single-use; you must store and delete them after the exchange. Our auth_codes.pop() does this.
  • Token invalid — In a real setup, the resource server must validate the token signature/expiry. Our minimal check just looks up the string. If you add a database, you’ll need to handle token revocation.
  • CSRF via state — If you don’t validate state, an attacker can inject a code. Always compare it to a value you stored earlier.
  • Client secret exposure — The token request must never go through the browser. If your SPA tries to do the exchange client-side, the secret leaks. Use the OAuth PKCE flow instead (that’s a future lesson).
  • Ports and CORS — If your client is on a different origin, you’ll see CORS errors in the browser. The token endpoint should ideally be same-origin or need explicit CORS headers.

What you learned & what's next

You’ve successfully set up a minimal OAuth 2.0 server. You can now:

  • Explain the core idea behind a minimal server — it’s the combination of an authorization endpoint, a token endpoint, and a storage layer.
  • Complete a practical exercise — you ran the authorization code flow from start to finish and saw the token issue and validate.

You’re no longer guessing how OAuth works; you’ve seen the wires. The next step in this track is OpenID Connect — that’s OAuth 2.0 plus an identity layer. You’ll extend your server to issue an ID token (a JWT with user claims) so that clients can log the user in, not just get an access token. That’s where the valet key starts carrying your user’s photo ID.

Practice recap

Now that your minimal server runs, try breaking it to learn: change the redirect_uri to a different port and watch the error, or reuse the same authorization code twice to see invalid_grant. Then, extend the /authorize endpoint to display the scope requested and require a user login form before consent. This hands-on tinkering will cement your understanding before you move on to OpenID Connect.

Common mistakes

  • Forgetting to use a single-use authorization code — reusing a code returns invalid_grant; always delete it after exchange.
  • Mismatched redirect_uri — the server compares exact strings, so a trailing slash or different port breaks the flow silently.
  • Sending the client secret in a browser-facing request — token exchanges must be server-to-server, or the secret is exposed.
  • Not validating the state parameter, which leaves your callback open to CSRF attacks.

Variations

  1. Use PKCE (Proof Key for Code Exchange) instead of a client secret — ideal for SPA or mobile apps where the secret can't be stored securely.
  2. Switch from in-memory storage to a database (like SQLite/PostgreSQL) to make authorization codes, clients, and tokens persistent across restarts.
  3. Adopt a full-featured OAuth provider like Authlib's Flask integration with JWT tokens rather than opaque strings, to enable stateless resource server validation.

Real-world use cases

  • Internal microservice auth: expose OAuth endpoints to let internal services get scoped tokens for accessing other APIs within a corporate network.
  • API testing harness: spin up a lightweight OAuth server in CI/CD to simulate an identity provider for integration tests, avoiding external service costs.
  • Partner API access: issue tokens to third-party developers who need limited, revocable access to your data, with scoped permissions per client.

Key takeaways

  • A minimal OAuth 2.0 server needs at least an authorization endpoint, a token endpoint, and storage for clients, codes, and tokens.
  • The authorization code flow protects the token by sending it only on the back channel from server to server, never through the browser.
  • Always validate the state parameter to prevent CSRF attacks on your callback URL.
  • Authorization codes are single-use and short-lived — exchange them once for an access token.
  • The resource server must validate the access token (e.g., check signature, expiry, or against a store) before granting access.
  • Choose your server complexity based on need: minimal for learning, full-featured for production.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.