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.
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
SessionMiddlewareenabled (backed byitsdangerous)
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 httpx04Required 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.
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",
},
)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-value06Register 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:
openidemailprofile(addoffline_accessfor refresh) - Add your redirect URI (next step)
- Copy the
client_idandclient_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.
# Development
http://localhost:8000/auth/callback
# Production
https://api.example.com/auth/callbackhttp://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: 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.json09Authorization 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.
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.
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:
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.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.
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 userfrom fastapi import APIRouter, Depends
from app.deps import require_user
router = APIRouter()
@router.get("/me")
async def me(user: dict = Depends(require_user)):
return user13Refresh 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.
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_token14Logout
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.
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_onlyinSessionMiddleware - Run with multiple
uvicorn/gunicornworkers behind a process manager
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
SessionMiddlewareis registered before your routers and itssecret_keyis stable across restarts. - mismatching_state — same root cause: the session cookie isn't surviving the redirect. Check
same_siteand cookie domain settings onSessionMiddleware. - 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.