Python

Sign in with NIA — FastAPI

Add “Sign in with NIA” to a FastAPI app with Authlib and the standard OpenID Connect flow. Follow the steps top to bottom — tick each one off as you go.

~15 minMediumAuthlib
Try OAuth Flow
Integration progress
0/16

01Overview

This guide adds “Sign in with NIA” to a FastAPI application using the standard OpenID Connect flow. NIA is a conformant OIDC provider, so you use Authlib — FastAPI's most common OAuth/OIDC client via its Starlette integration — with no NIA-specific SDK.

FastAPI runs as a confidential client: the browser is redirected to NIA to sign in, and your FastAPI server exchanges the returned code for tokens using your client secret. Authlib handles PKCE, state, and ID-token signature validation for you.

  • Flow: Authorization Code + PKCE (S256)
  • Tokens: RS256 access & ID tokens, opaque refresh token
  • You write: three routes — login, callback, logout

02Requirements

  • Python 3.10+, FastAPI, and uvicorn
  • A NIA account with access to the client dashboard
  • A registered OAuth client (client_id + client_secret)
  • Your app's redirect URI registered on that client
  • Starlette's SessionMiddleware enabled (backed by itsdangerous)

03Installation

Install Authlib, itsdangerous (for signed session cookies), and httpx — Authlib's async HTTP client for fetching the discovery document and JWKS.

pip install "authlib" itsdangerous httpx

04Required OAuth library

Register NIA once with Authlib's Starlette integration, and mount SessionMiddleware so Authlib can persist the PKCE verifier and state between the login redirect and the callback. The server_metadata_url points at NIA's discovery document — every endpoint, the JWKS URL, and supported algorithms are configured automatically from it.

app/auth.py
from authlib.integrations.starlette_client import OAuth
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware

from app.config import settings

app = FastAPI()

app.add_middleware(SessionMiddleware, secret_key=settings.SESSION_SECRET)

oauth = OAuth()

oauth.register(
    name="nia",
    server_metadata_url=f"{settings.NIA_ISSUER}/.well-known/openid-configuration",
    client_id=settings.NIA_CLIENT_ID,
    client_secret=settings.NIA_CLIENT_SECRET,
    client_kwargs={
        "scope": "openid email profile",
        # NIA requires PKCE — Authlib generates the verifier/challenge for you.
        "code_challenge_method": "S256",
    },
)
Add offline_access to the scope only if you need refresh tokens (see step 14).

05Environment Variables

Never hard-code secrets. Keep them in a .env file (git-ignored) and load them through a settings object.

NIA_ISSUER=https://dev-api.niaauth.com
NIA_CLIENT_ID=your-client-id
NIA_CLIENT_SECRET=your-client-secret
SESSION_SECRET=replace-with-a-long-random-value

06Register OAuth Client

In the NIA dashboard, create the client your FastAPI app will authenticate as:

  • Open Dashboard → Clients → New client
  • Client type: Web (confidential)
  • Allowed scopes: openid email profile (add offline_access for refresh)
  • Add your redirect URI (next step)
  • Copy the client_id and client_secret — the secret is shown once

07Redirect URI

NIA matches redirect URIs exactly — scheme, host, port, and path must be identical, with no trailing-slash difference. Register one URI per environment.

redirect URIs
# Development
http://localhost:8000/auth/callback

# Production
https://api.example.com/auth/callback
A mismatch is the #1 integration error. http://localhost:8000/auth/callback/ (trailing slash) will be rejected.

08Issuer

The issuer is the single URL every OIDC library needs. Its discovery document advertises all endpoints below.

issuer & endpoints
Issuer:        https://dev-api.niaauth.com
Discovery:     https://dev-api.niaauth.com/.well-known/openid-configuration
Authorization: https://dev-api.niaauth.com/oauth/authorize
Token:         https://dev-api.niaauth.com/oauth/token
UserInfo:      https://dev-api.niaauth.com/oauth/userinfo
JWKS:          https://dev-api.niaauth.com/oauth/jwks.json

09Authorization Flow

Add a login route that redirects to NIA. Authlib stores the PKCE verifier and state in the Starlette session, so SessionMiddleware must be active. request.url_for builds the callback URL from the named route below.

app/routes/auth.py
from fastapi import APIRouter, Request

from app.auth import oauth

router = APIRouter()


@router.get("/auth/login")
async def login(request: Request):
    redirect_uri = request.url_for("callback")
    return await oauth.nia.authorize_redirect(request, redirect_uri)

10Token Exchange

On callback, exchange the authorization code for tokens. authorize_access_token verifies state, sends the PKCE verifier, exchanges the code, and validates the ID token — all in one async call. The route is named callback so request.url_for("callback") above resolves to it.

app/routes/auth.py
from fastapi.responses import RedirectResponse


@router.get("/auth/callback", name="callback")
async def callback(request: Request):
    token = await oauth.nia.authorize_access_token(request)
    userinfo = token["userinfo"]  # validated ID-token claims

    request.session["user"] = dict(userinfo)
    request.session["nia_id_token"] = token["id_token"]
    request.session["nia_refresh"] = token.get("refresh_token")
    return RedirectResponse("/")

11JWKS Validation

NIA signs ID tokens with RS256. Authlib fetches the public keys from the JWKS endpoint (discovered automatically), caches them, and verifies the signature, iss, aud, exp, and nonce during authorize_access_token. You do not verify manually.

To confirm what was validated, inspect the parsed claims:

verify (illustrative)
token = await oauth.nia.authorize_access_token(request)
claims = token["userinfo"]

assert claims["iss"] == settings.NIA_ISSUER      # issuer
assert claims["aud"] == settings.NIA_CLIENT_ID    # audience = your client
# signature (RS256, via JWKS), exp and nonce are already verified by Authlib.
Keys rotate. Because Authlib refetches JWKS by kid, rotation needs no code change on your side.

12Session Management

Once request.session["user"] is set, read it back through a small dependency and raise 401 if it's missing. Inject the dependency into any route that requires an authenticated user.

app/deps.py
from fastapi import HTTPException, Request, status


def require_user(request: Request) -> dict:
    user = request.session.get("user")
    if not user:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
    return user
app/routes/profile.py
from fastapi import APIRouter, Depends

from app.deps import require_user

router = APIRouter()


@router.get("/me")
async def me(user: dict = Depends(require_user)):
    return user

13Refresh Tokens

If you requested offline_access, NIA returns a refresh token. Exchange it for a fresh access token when the current one expires. NIA rotates refresh tokens, so always store the new one.

app/refresh.py
from fastapi import Request

from app.auth import oauth


async def refresh_session(request: Request):
    refresh_token = request.session.get("nia_refresh")
    if not refresh_token:
        return None

    new_token = await oauth.nia.fetch_access_token(
        grant_type="refresh_token",
        refresh_token=refresh_token,
    )
    # NIA rotates the refresh token — persist the replacement.
    request.session["nia_refresh"] = new_token.get("refresh_token", refresh_token)
    return new_token

14Logout

Clear the Starlette session and send the user to NIA's end-session endpoint so they are signed out of NIA too. Register your post_logout_redirect_uri on the client.

app/routes/auth.py
from urllib.parse import urlencode

from fastapi.responses import RedirectResponse

from app.config import settings


@router.get("/auth/logout")
async def logout(request: Request):
    id_token = request.session.get("nia_id_token")
    request.session.clear()

    params = urlencode({
        "id_token_hint": id_token or "",
        "post_logout_redirect_uri": str(request.url_for("home")),
    })
    return RedirectResponse(f"{settings.NIA_ISSUER}/oauth/logout?{params}")

15Deployment

Before going live:

  • Register the production redirect URI (HTTPS) on the client
  • Set all env vars in your host — never commit .env
  • Serve behind HTTPS, ideally with a reverse proxy (nginx, Caddy) terminating TLS
  • Mark the session cookie https_only in SessionMiddleware
  • Run with multiple uvicorn/gunicorn workers behind a process manager
app/main.py (production)
app.add_middleware(
    SessionMiddleware,
    secret_key=settings.SESSION_SECRET,
    same_site="lax",   # required for the OIDC redirect round-trip
    https_only=True,   # only send the session cookie over HTTPS
)

16Troubleshooting

  • redirect_uri_mismatch — the URI must match the registered one exactly (no trailing slash, correct scheme/port).
  • invalid_client — wrong client_secret, or the client's token-auth method differs from what Authlib sends.
  • invalid_grant / PKCE failed — the session was lost between login and callback. Confirm SessionMiddleware is registered before your routers and its secret_key is stable across restarts.
  • mismatching_state — same root cause: the session cookie isn't surviving the redirect. Check same_site and cookie domain settings on SessionMiddleware.
  • invalid_scope — the scope isn't in the client's allowed_scopes; add it in the dashboard.
  • ID-token signature — usually server clock skew; sync time via NTP.