Building an internal IT portal with Flask, Okta OIDC, and a GitHub username lookup
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-verifierlibrary handles JWT validation against Okta's JWKS, so I'm not hand-rolling token verification.
The flow is the standard OIDC authorization code flow:
- User hits
/auth/login, which redirects to Okta's/v1/authorizewith scopesopenid email profile groups. - Okta authenticates the user and redirects back to
/auth/callbackwith acode. - The app exchanges the code at Okta's
/v1/tokenfor an access token and ID token. - Both tokens are verified (signature, issuer, audience).
- 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://defaultaudience; the ID token is also checked against the client ID and the expected nonce. - The
groupsclaim is what drives admin gating. If the user is in the IT Admins group, theUserobject getsadmin=True, and the rest of the app can decorate routes / template branches offcurrent_user.admin. - The user record is keyed by
sub, not by email — emails change,subdoesn'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 FalseI 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', 400Rate 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
/healthpath is exempted via arequest_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)}, 429Talisman: 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 searchThe 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/searchreturns the HTML shell — a Bootstrap table that DataTables hydrates client-side./gh/search.jsonreturns 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-verifierdoing 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_requiredeverywhere, with admin gating off the Oktagroupsclaim.
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.