Back to posts

Building an internal IT portal with Flask, Okta OIDC, and a GitHub username lookup

pythonflaskoktaoidcsecuritygithub

For internal tooling, I kept running into the same friction: small Flask apps that should be a five-minute write, but in practice need real authentication, real session handling, and real CSP/CSRF/rate-limit story before they can sit on the corporate network. Rolling those by hand every time is how mistakes happen.

This post walks through the auth and security layer I settled on for an internal IT engineering portal, and one of the first features it shipped: a self-service page where any employee can look up a coworker's GitHub username by work email.

Why Okta OIDC

The company already had Okta as the IdP for everything else, so OIDC was the right call:

  • No password handling in my app — Okta owns that.
  • Group claims come back in the userinfo response, so admin gating is "is this user in the right Okta group?"
  • The okta-jwt-verifier library handles JWT validation against Okta's JWKS, so I'm not hand-rolling token verification.

The flow is the standard OIDC authorization code flow:

  1. User hits /auth/login, which redirects to Okta's /v1/authorize with scopes openid email profile groups.
  2. Okta authenticates the user and redirects back to /auth/callback with a code.
  3. The app exchanges the code at Okta's /v1/token for an access token and ID token.
  4. Both tokens are verified (signature, issuer, audience).
  5. The userinfo endpoint is called for the email + group memberships, and a Flask-Login session is created.

The login redirect

APP_STATE = 'ApplicationState' NONCE = 'Nonce' @auth.route('/login') def login(): okta_login_url = ( f'{config["issuer"]}/v1/authorize' f'?client_id={config["client_id"]}' f'&response_type=code' f'&scope=openid email profile groups' f'&redirect_uri={config["redirect_uri"]}' f'&state=active' ) return redirect(okta_login_url)

config here is loaded from GCP Secret Manager at startup — the client ID, client secret, issuer URL, token URI, userinfo URI, and redirect URI all live in a single secret payload. Nothing OIDC-related ships in the image or the env.

The callback

@auth.route('/callback') def callback(): headers = {'Content-Type': 'application/x-www-form-urlencoded'} code = request.args.get("code") if not code: return "The code was not returned or is not accessible", 403 query_params = { 'grant_type': 'authorization_code', 'code': code, 'redirect_uri': config["redirect_uri"], } query_params = requests.compat.urlencode(query_params) exchange = requests.post( config["token_uri"], headers=headers, data=query_params, auth=(config["client_id"], config["client_secret"]), ).json() if not exchange.get("token_type"): return "Unsupported token type. Should be 'Bearer'.", 403 access_token = exchange["access_token"] id_token = exchange["id_token"] if not is_access_token_valid(access_token, config["issuer"]): return "Access token is invalid", 403 if not is_id_token_valid(id_token, config["issuer"], config["client_id"], NONCE): return "ID token is invalid", 403 userinfo_response = requests.get( config["userinfo_uri"], headers={'Authorization': f'Bearer {access_token}'}, ).json() unique_id = userinfo_response["sub"] user_email = userinfo_response["email"] user_name = userinfo_response["given_name"] user_group = userinfo_response.get("groups", []) user_admin = ADMIN_GROUP in user_group user = User(id_=unique_id, name=user_name, email=user_email, admin=user_admin) if not User.get(unique_id): User.create(unique_id, user_name, user_email, user_admin) login_user(user) return redirect(url_for('main.profile'))

A few things worth pointing out:

  • Both tokens are verified before the user is logged in. The access token is checked against the api://default audience; the ID token is also checked against the client ID and the expected nonce.
  • The groups claim is what drives admin gating. If the user is in the IT Admins group, the User object gets admin=True, and the rest of the app can decorate routes / template branches off current_user.admin.
  • The user record is keyed by sub, not by email — emails change, sub doesn't.

Token verification

from okta_jwt_verifier import AccessTokenVerifier, IDTokenVerifier def is_access_token_valid(token, issuer): jwt_verifier = AccessTokenVerifier(issuer=issuer, audience='api://default') try: loop.run_until_complete(jwt_verifier.verify(token)) return True except Exception as e: print(f"Token verification failed: {e}") return False def is_id_token_valid(token, issuer, client_id, nonce): jwt_verifier = IDTokenVerifier(issuer=issuer, client_id=client_id, audience='api://default') try: loop.run_until_complete(jwt_verifier.verify(token, nonce=nonce)) return True except Exception: return False

I lean on Okta's verifier rather than rolling my own JWKS fetch + RSA verify. The library handles key rotation, signature checks, expiry, and audience binding — all things that are easy to subtly get wrong.

Security features layered on top

Auth is only the front door. The rest of the app is hardened with a stack of Flask extensions, all wired up in create_app():

csrf = CSRFProtect() limiter = Limiter( key_func=get_remote_address, headers_enabled=True, retry_after="http-date", storage_uri=f"redis://{os.getenv('REDIS_HOST')}:{os.getenv('REDIS_PORT')}", strategy="fixed-window", ) # ... csrf.init_app(app) limiter.init_app(app) Session(app) Talisman(app, **app.config['TALISMAN_CONFIG'])

Session cookies

Sessions live in Redis (flask-session), not in a signed client-side cookie. This means:

  • Logging a user out actually invalidates the session server-side.
  • Session payloads can hold more than 4KB without bumping into cookie size limits.
  • A leaked cookie alone is not enough — the session ID must still match a live record.

Cookie flags:

SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = "Strict" PERMANENT_SESSION_LIFETIME = timedelta(hours=1) REMEMBER_COOKIE_SECURE = True REMEMBER_COOKIE_HTTPONLY = True REMEMBER_COOKIE_SAMESITE = "Strict"

Secure + HttpOnly + SameSite=Strict is the table-stakes set. The 1-hour session lifetime keeps abandoned tabs from staying authenticated all day.

CSRF

Flask-WTF's CSRFProtect is enabled globally:

WTF_CSRF_ENABLED = True WTF_CSRF_SSL_STRICT = True WTF_CSRF_TIME_LIMIT = 3600 WTF_CSRF_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']

Failures route through a custom handler that logs the offending IP, so we have an audit trail when something trips it:

@app.errorhandler(CSRFError) def handle_csrf_error(e): app_logger.warning(f"CSRF validation failed for IP: {get_client_ip()}") return 'CSRF validation failed', 400

Rate limiting

flask-limiter with Redis as the backend, and per-endpoint rules in config:

RATELIMIT_RULES = { 'main.profile': '60 per minute', 'main.lookup': '60 per minute', 'gh_lookup.search': '100 per hour', 'auth.login': '5 per minute', 'auth.callback': '10 per minute', }

A few notes from running this in practice:

  • The login and callback endpoints get the tightest limits. Anything that talks to Okta is somewhere a botnet will happily burn cycles for you.
  • The /health path is exempted via a request_filter, so the load balancer's health checks never get rate-limited.
  • The 429 handler logs the client IP and the limit description, which is what you want when you're triaging "why did this user get blocked at 3am?"
@app.errorhandler(429) def ratelimit_handler(e): app_logger.warning( f"Rate limit exceeded for IP: {get_client_ip()} - " f"Path: {request.path} - " f"Limit: {getattr(e, 'description', 'Unknown limit')}" ) return {"error": "ratelimit exceeded", "message": str(e.description)}, 429

Talisman: CSP, HSTS, and friends

Flask-Talisman handles the security headers I'd otherwise have to wire up by hand:

TALISMAN_CONFIG = { 'content_security_policy': { 'default-src': "'self'", 'script-src': "'self' https://cdnjs.cloudflare.com https://cdn.datatables.net ...", 'style-src': "'self' https://cdnjs.cloudflare.com ...", 'img-src': "'self' data: ...", 'font-src': "'self' data: ...", 'connect-src': "'self'" }, 'force_https': False, # handled by the load balancer in front 'strict_transport_security': True, 'strict_transport_security_preload': True, 'strict_transport_security_max_age': 31536000, 'frame_options': 'DENY', 'x_content_type_options': 'nosniff', 'x_xss_protection': True }

force_https is False because the GCP load balancer in front of the app already does TLS termination + HTTPS redirect — turning it on inside the app caused redirect loops. HSTS is still enabled, with a one-year max-age and preload.

The CSP is the most useful piece: default-src 'self' plus an explicit allowlist for the few CDNs DataTables and Bootstrap need. Everything else gets blocked, including any inline script an attacker might try to inject through a stored-XSS bug.

Secrets

No credentials in env or in the image. At startup, the app pulls secrets from GCP Secret Manager:

client = secretmanager.SecretManagerServiceClient() def get_secret(project_id, secret_id): path = client.secret_version_path(project_id, secret_id, 'latest') response = client.access_secret_version(request={'name': path}) return response.payload.data.decode('UTF-8') # ... app.config['DB_CONN'] = get_secret(project_id, db_conn) app.config['DB_USER'] = get_secret(project_id, db_user) app.config['DB_PASS'] = get_secret(project_id, db_pass) app.config['DB_ROOT_CERT'] = get_secret(project_id, db_root_cert) app.config['DB_CLIENT_CERT'] = get_secret(project_id, db_client_cert) app.config['DB_CLIENT_KEY'] = get_secret(project_id, db_client_key) app_secret_key = get_secret(project_id, os.getenv('APP_SECRET_KEY')) app.config.update({'SECRET_KEY': app_secret_key})

The SECRET_KEY Flask uses to sign session IDs is itself a Secret Manager value, not a hardcoded string — rotation just means cutting a new secret version and rolling the pods.

Audit logging

Every authenticated request gets a log line with the user's email and the client IP:

@app.before_request @log_request_info(app_logger) def before_request(): if current_user.is_authenticated and not should_skip_logging(request.path): app_logger.info( f'Authenticated user: {current_user.email} from IP: {get_client_ip()}' )

Health-check paths are filtered out so they don't drown the signal. CSRF failures and rate-limit hits log at WARNING. Logs ship to Elastic APM via elasticapm.contrib.flask.ElasticAPM, so I can pivot from "this user's profile page is slow" to "this DB call is the long pole" without leaving the dashboard.

The GitHub username lookup page

With auth and the security baseline in place, the actual feature is small.

The problem it solves: I'd get pinged in chat constantly with "what's so-and-so's GitHub username — I want to add them as a reviewer." There's a SSO mapping between work email and GitHub user, but it isn't visible to non-admins. So I shipped a page that exposes the email-to-GitHub-username mapping to anyone who is logged in.

The blueprint

from flask import Blueprint gh_lookup = Blueprint('gh_lookup', __name__, template_folder='templates', static_folder='static') from . import search

The routes

@gh_lookup.route('/search') @login_required def search(): """HTML template endpoint""" return render_template('gh_lookup.html') @gh_lookup.route('/search.json') @login_required def search_json(): """JSON data endpoint""" db_connector = current_app.config['DB_CONNECTOR'] try: rtn_result = db_connector.enumerate_table(os.getenv("TBL_GITHUB_USERS")) except sqlalchemy.exc.PendingRollbackError: db_connector.session.rollback() raise return jsonify([dict(row) for row in rtn_result])

Two endpoints, both gated by @login_required:

  • /gh/search returns the HTML shell — a Bootstrap table that DataTables hydrates client-side.
  • /gh/search.json returns the actual rows: last-pull date, full name, work email, GitHub username.

Splitting HTML and JSON is what makes the page snappy. The HTML is a tiny static template; DataTables handles paging, sorting, and search entirely in the browser against the JSON payload. No server-side query parsing, no SQL injection surface, no template-rendered user input.

The data table itself is populated by a separate sync job that pulls GitHub org membership into a Postgres table on a schedule, keyed by work email. The Flask app only reads.

The template

{% extends "base.html" %} {% set active_page = "gh_lookup" %} {% block content %} <div class="container mt-4"> <h2>GitHub User Lookup</h2> <p>Employee GitHub usernames mapped to their work email.</p> <div class="table-responsive"> <table id="gh-lookup-table" class="table table-striped table-bordered"> <thead> <tr> <th>Last Pull Date</th> <th>Name</th> <th>Email Address</th> <th>GH Username</th> </tr> </thead> <tbody></tbody> </table> </div> </div> {% endblock %}

Personalizing the profile page

The same email-to-GitHub-username table powers the /profile page, where the currently logged-in user sees their own assignments — GitHub username, laptop serial, distribution lists they're on, distribution lists they own. Because Okta gives us a verified email in the userinfo response, the lookup is just:

@main.route('/profile') @login_required def profile(): db_connector = current_app.config['DB_CONNECTOR'] assigned_github = db_connector.enumerate_table( table_name=os.getenv('TBL_LOOKUP_GITHUB_UID'), where={'email': current_user.email}, ) # ... return render_template('profile.html', user=current_user, assigned_github=assigned_github, ...)

current_user.email is trustworthy here because it came from a verified ID token, not a form field. That's what Okta is buying us.

Wrapping up

The pattern that emerged — and that I now reuse for every internal Flask tool — is:

  • Okta OIDC for identity, with okta-jwt-verifier doing the cryptography.
  • Flask-Login for the session lifecycle, flask-session + Redis for server-side state.
  • Talisman for headers, Flask-WTF for CSRF, flask-limiter for abuse control.
  • GCP Secret Manager for all credentials, including the Flask SECRET_KEY.
  • @login_required everywhere, with admin gating off the Okta groups claim.

Once that scaffold is in place, shipping a feature like the GitHub username lookup is two routes and a template. The hard part is the platform; the features are easy.