n4nAI

Implementing OAuth2 login for a customer-facing LLM app

Step-by-step tutorial for implementing OAuth2 login in a customer-facing LLM app with Python, Authlib, and an OpenAI-compatible model gateway.

n4n Team3 min read558 words

Audio narration

Coming soon — every post will get a voice note here.

When implementing oauth2 login llm app backends, the core problem is mapping an external identity to a server-side session and then using that session to broker calls to a model gateway without exposing provider keys. This tutorial builds a minimal Flask service that authenticates users via Google’s OAuth2 authorization code flow and proxies chat requests to an OpenAI-compatible endpoint.

Prerequisites

  • Python 3.10+ and pip
  • A Google Cloud OAuth2 client (authorized redirect URI http://localhost:5000/callback)
  • Installed packages: flask, authlib, openai, python-dotenv
  • An API key for an OpenAI-compatible LLM gateway. We’ll point LLM_BASE at https://api.n4n.ai/v1, a single endpoint that addresses 240+ models and honors client routing directives.

Create a virtual environment and install dependencies:

python -m venv venv && source venv/bin/activate
pip install flask authlib openai python-dotenv

Store configuration in .env:

FLASK_SECRET=change-me-in-prod
GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=xxxx
LLM_API_KEY=sk-your-key
LLM_BASE=https://api.n4n.ai/v1

1. Scaffold the Flask app and OAuth client

Authlib handles the OAuth2 dance. Register Google as a remote app and expose /login, /callback, /logout.

from flask import Flask, session, redirect, url_for, request, jsonify
from authlib.integrations.flask_client import OAuth
import os
from dotenv import load_dotenv

load_dotenv()
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET"]
oauth = OAuth(app)

google = oauth.register(
    name="google",
    client_id=os.environ["GOOGLE_CLIENT_ID"],
    client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
    access_token_url="https://oauth2.googleapis.com/token",
    authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
    userinfo_endpoint="https://openidconnect.googleapis.com/v1/userinfo",
    client_kwargs={"scope": "openid email profile"},
)

@app.route("/login")
def login():
    redirect_uri = url_for("callback", _external=True)
    return google.authorize_redirect(redirect_uri)

@app.route("/callback")
def callback():
    google.authorize_access_token()
    userinfo = google.get("userinfo").json()
    session["user"] = userinfo["email"]
    return redirect("/")

@app.route("/logout")
def logout():
    session.pop("user", None)
    return redirect("/")

Checkpoint: redirect works

Start the app (flask --app app run) and hit /login:

curl -i http://localhost:5000/login

Expected response is a 302 to https://accounts.google.com/...&redirect_uri=http://localhost:5000/callback. After consent in a browser, /callback stores the email in session and redirects to /.

2. Protect the LLM proxy route

Never call the model gateway from the browser with a shared key. Require an authenticated session, then forward the request server-side.

from openai import OpenAI

@app.route("/api/chat", methods=["POST"])
def chat():
    if "user" not in session:
        return jsonify(error="unauthorized"), 401
    payload = request.json or {}
    if "messages" not in payload:
        return jsonify(error="messages required"), 400

    client = OpenAI(
        base_url=os.environ["LLM_BASE"],
        api_key=os.environ["LLM_API_KEY"],
    )
    resp = client.chat.completions.create(
        model=payload.get("model", "gpt-4o-mini"),
        messages=payload["messages"],
        temperature=0.7,
    )
    # The gateway provides per-token usage metering; attribute cost to the user.
    usage = resp.usage.model_dump()
    print(f"user={session['user']} tokens={usage}")
    return jsonify({
        "content": resp.choices[0].message.content,
        "usage": usage,
    })

The OpenAI Python client works unchanged against any OpenAI-compatible base URL. Because the gateway forwards provider cache-control hints and supports automatic fallback when a provider is degraded, the proxy stays resilient without extra code.

Checkpoint: authenticated call

After logging in via browser, capture the session cookie:

curl -c cookies.txt -L http://localhost:5000/login
# complete Google consent in browser, then cookies.txt holds session
curl -b cookies.txt -X POST http://localhost:5000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Say hi in one word"}]}'

Expected JSON:

{
  "content": "Hi",
  "usage": {"prompt_tokens": 12, "completion_tokens": 1, "total_tokens": 13}
}

A request without the cookie returns 401 {"error":"unauthorized"}.

3. Minimal frontend integration

A customer-facing LLM app needs a UI. This snippet calls the proxy with same-origin credentials:

<button onclick="send()">Ask</button>
<script>
async function send() {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "same-origin",
    body: JSON.stringify({
      messages: [{ role: "user", content: "Explain OAuth2 briefly" }]
    })
  });
  if (res.status === 401) { window.location = "/login"; return; }
  const data = await res.json();
  document.body.insertAdjacentHTML("beforeend",
    `<p>${data.content} (${data.usage.total_tokens} tokens)</p>`);
}
</script>

The browser never sees LLM_API_KEY. The session cookie is the only bearer of identity.

4. Hardening for production

  • Cookie security: set app.config["SESSION_COOKIE_SECURE"] = True and HTTPONLY/SAMESITE when serving over HTTPS.
  • PKCE: for SPAs or mobile, register a public client and pass code_challenge_method="S256" to authorize_redirect. Server-side sessions with confidential clients are simpler but still benefit from PKCE.
  • Strict redirect URIs: Google rejects unknown redirects, but validate redirect_uri against an allowlist if you proxy multiple domains.
  • Rate limiting: wrap /api/chat with Flask-Limiter keyed on session["user"] to prevent one user from draining quota.
  • Token refresh: the stored OAuth token isn’t used after login here, but if you call Google APIs later, persist token from authorize_access_token() and use Authlib’s auto-refresh.

5. Why broker instead of direct browser calls

If you approached implementing oauth2 login llm app by shipping the gateway key to the client and only using OAuth to gate UI, you’d leak quota the moment the bundle is decompiled. Brokering through a session lets you enforce per-user rate limits, mutate model routing, and log usage. The pattern for implementing oauth2 login llm app described here scales to multiple identity providers (GitHub, Microsoft) by adding more oauth.register blocks and normalizing their userinfo into session["user"].

6. Extending to multi-model routing

Because the gateway honors client routing directives, you can let the authenticated user pick a model alias in the request body:

model = payload.get("model", "anthropic/claude-3.5-sonnet")

No code change is needed when the gateway adds new providers; the same /api/chat route works. Combine that with the per-token metering already printed to your logs, and you have a foundation for billing each customer by actual consumption.

Keep the OAuth client secret server-side, rotate FLASK_SECRET periodically, and treat the session as the only trust boundary. That’s the whole system.

Tagsoauth2logintutorialllm-app

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All oauth2 & bearer token auth for llm platforms posts →