A CTFd plugin that turns your CTFd instance into an OAuth2 Authorization Server
and OpenID Connect (OIDC) Identity Provider. Other applications can then offer
"Log in with CTFd", and CTFd issues authorization codes, access / refresh
tokens, and signed OIDC id_tokens tied to your CTFd accounts.
Supports both public and confidential OAuth2 applications.
| Application type | Authenticates with | Requirement |
|---|---|---|
| Confidential (server-side web apps) | a client_secret (Basic or POST) |
secret stored securely on a server |
| Public (SPAs, mobile, CLI, desktop) | no secret | PKCE is mandatory (RFC 7636) |
Built on Authlib, so the OAuth2 / OIDC mechanics (grants, token validation, PKCE, revocation, introspection) are handled by a mature, security-reviewed library.
- Features
- Installation
- Configuration
- Registering an application
- Declarative provisioning from YAML
- Trusted (first-party) applications
- Endpoints
- Scopes & claims
- Example flows
- Security notes
- Development & contributing
- Authorization Code grant with PKCE (
S256/plain). - Refresh Token grant (optional, per application).
- OpenID Connect
id_tokens signed with a locally-managed RSA key (RS256). - UserInfo, Revocation (RFC 7009) and Introspection (RFC 7662) endpoints.
- Discovery:
/.well-known/openid-configuration+ a JWKS endpoint. - Public & confidential clients, with PKCE enforced for public clients.
- Admin UI to register / edit / delete applications and rotate secrets, surfaced under the admin Plugins menu.
- Declarative provisioning of applications from a mounted YAML file (IaC).
- Trusted (first-party) apps that skip the user consent screen.
- Authorized Apps tab on the user Settings page to review and revoke access.
- Rich claims: user
profile,email, CTFd team and admin / role info. - Ships as both a classic CTFd plugin and a published OCI image for Kubernetes volume mounting.
The plugin folder name becomes its Python import name. This document assumes
oidc_provider. Keep that name unless you also update references.
Like any other CTFd plugin: drop it into CTFd/plugins/ and install its
dependencies into the same environment CTFd runs in.
# from the root of your CTFd checkout
git clone https://github.com/AYDEV-FR/ctfd-oidc-provider.git \
CTFd/plugins/oidc_provider
pip install -r CTFd/plugins/oidc_provider/requirements.txt
# restart CTFdOn first run the plugin:
- creates its tables (
oauth2_clients,oauth2_codes,oauth2_tokens) automatically — no manual migration needed; - generates an RSA signing key at
oidc_provider/jwks_private.json.
Keep
jwks_private.jsonprivate and persistent. Deleting it invalidates every previously issuedid_token. In multi-worker / multi-host deployments, make sure all workers read the same key file (shared volume) or pre-seed one. See Configuration for pinning the key via env var.
Every push to main publishes a small OCI image that contains only the
plugin and its Python dependencies to GitHub Container Registry:
ghcr.io/aydev-fr/ctfd-oidc-provider:latest
Image layout:
| Path | Contents |
|---|---|
/plugin/oidc_provider/ |
the plugin package (code, templates, assets) |
/plugin/deps/ |
vendored Python deps (Authlib, PyYAML) for PYTHONPATH |
This lets you run the upstream ctfd/ctfd image unchanged and inject the
plugin at runtime with an initContainer that copies it into shared volumes.
A complete, ready-to-apply example lives in k8s/; the essence:
spec:
volumes:
- name: oidc-plugin # holds the plugin package
emptyDir: {}
- name: oidc-deps # holds the vendored python deps
emptyDir: {}
initContainers:
- name: install-oidc-plugin
image: ghcr.io/aydev-fr/ctfd-oidc-provider:latest
command: ["sh", "-c", "cp -r /plugin/oidc_provider/. /dst-plugin/ && cp -r /plugin/deps/. /dst-deps/"]
volumeMounts:
- { name: oidc-plugin, mountPath: /dst-plugin }
- { name: oidc-deps, mountPath: /dst-deps }
containers:
- name: ctfd
image: ctfd/ctfd:3.8.5
env:
# make the vendored deps importable, and pin a stable issuer/key
- { name: PYTHONPATH, value: /opt/oidc-deps }
- { name: OIDC_PROVIDER_ISSUER, value: https://ctf.example.com }
volumeMounts:
# mount ONLY the plugin subdir, so built-in CTFd plugins stay intact
- { name: oidc-plugin, mountPath: /opt/CTFd/CTFd/plugins/oidc_provider }
- { name: oidc-deps, mountPath: /opt/oidc-deps }Notes:
- Mount the plugin at the
oidc_providersubdirectory, never over the wholeplugins/folder — otherwise CTFd's built-in plugins disappear. - Persist the signing key: set
OIDC_PROVIDER_JWK_FILEto a path on aPersistentVolume(see Configuration), or all pods will generate different keys. - Pin a real release tag (e.g.
:v1.0.0) in production instead of:latest.
The repo ships a docker-compose.yml + Dockerfile that run CTFd with the
plugin bind-mounted, so you can test and edit it live:
docker compose up --buildOpen http://localhost:8000, complete the CTFd setup wizard, and you'll see OIDC Apps under the admin Plugins menu.
- The
Dockerfileextendsctfd/ctfdandpip installs the requirements. AUTHLIB_INSECURE_TRANSPORT=1is set so OAuth2 works over plain HTTP locally — never in production.- DB / uploads / logs persist in named volumes (
docker compose down -vwipes them). - An example app set is auto-provisioned from
oidc_apps.example.yaml.
Try the full login flow with the bundled demo relying-party:
docker compose --profile demo up --buildThen open http://localhost:5000 → Login with CTFd. Because the demo app is provisioned as trusted, consent is auto-approved and you get the issued tokens
userinfoback as JSON.
All settings are read from environment variables (or the matching CTFd config key).
| Variable | Default | Purpose |
|---|---|---|
OIDC_PROVIDER_ISSUER |
request host | The iss value in id_tokens and discovery. Pin this behind a proxy. |
OIDC_PROVIDER_APPS_FILE |
unset | Path to a YAML file of apps to provision on startup (see below). |
OIDC_PROVIDER_JWK_FILE |
oidc_provider/jwks_private.json |
Where the RSA signing key is read/written. Put it on a shared/persistent volume. |
AUTHLIB_INSECURE_TRANSPORT |
unset | Set to 1 to allow plain HTTP. Local dev only. |
Behind a reverse proxy, make sure CTFd sees the correct external scheme/host
(CTFd's ReverseProxy setting / X-Forwarded-* headers) so generated URLs and
the issuer are correct.
Go to Admin → Plugins → OIDC Apps → Register application and choose:
- Confidential — a client secret is generated and shown once. Use
client_secret_basic(default) orclient_secret_post. - Public — no secret; the app must use PKCE.
Set one or more exact redirect URIs, pick the scopes, optionally enable refresh tokens, and optionally mark the app trusted (skip consent).
Define applications as code and mount the file into the container; set
OIDC_PROVIDER_APPS_FILE to its path. On every startup the plugin
upserts the listed apps (matched by client_id).
# oidc_apps.yaml
applications:
- name: My Web App
client_id: my-web-app # required — the stable upsert key
client_secret: super-secret # required for confidential apps
type: confidential # "confidential" (default) or "public"
trusted: false # true => skip the consent screen
client_uri: https://app.example.com
redirect_uris:
- https://app.example.com/callback
scopes: [openid, profile, email, team]
grant_types: [authorization_code, refresh_token]
token_endpoint_auth_method: client_secret_basic
- name: My SPA
client_id: my-spa
type: public # no secret; PKCE enforced
redirect_uris:
- https://spa.example.com/callback
scopes: [openid, profile]- Apps are upserted every startup — edit + restart to apply changes.
- A confidential app with no
client_secretgets one generated and logged once. - Removing an entry does not delete the app (delete it from the admin UI).
A trusted application skips the consent screen: a logged-in user sent to
/oauth/authorize is auto-approved and redirected straight back with a code.
Use this only for first-party apps you fully control (your own wiki/forum behind
the same login), since the user is never asked to confirm.
Enable it via the admin UI (Trusted application checkbox) or YAML
(trusted: true). Everything else — PKCE, scopes, token issuance, revocation —
behaves identically; only the interactive consent step is bypassed.
| Purpose | URL | Auth |
|---|---|---|
| Discovery | /.well-known/openid-configuration |
none |
| JWKS (public keys) | /oauth/jwks |
none |
| Authorization | /oauth/authorize |
CTFd login + consent |
| Token | /oauth/token |
client auth / PKCE |
| UserInfo | /oauth/userinfo |
Bearer access token |
| Revocation | /oauth/revoke |
client auth |
| Introspection | /oauth/introspect |
client auth |
| Admin: manage apps | /admin/oidc |
CTFd admin |
| User: authorized apps | /oidc/authorizations |
CTFd user |
| Scope | Claims returned |
|---|---|
openid |
sub (CTFd user id) |
profile |
name, preferred_username, website, locale, profile, is_admin (bool), role (admin/user) |
email |
email, email_verified |
team |
team, team_id (and team_website / team_country when set) — team-mode only; null if the user has no team |
These claims appear both in the OIDC id_token and in the /oauth/userinfo
response, filtered by the scopes the user granted.
GET /oauth/authorize?response_type=code
&client_id=CLIENT_ID
&redirect_uri=https://app.example.com/callback
&scope=openid%20profile%20email
&state=RANDOM
After consent, CTFd redirects back with ?code=...&state=.... Exchange it:
curl -u CLIENT_ID:CLIENT_SECRET \
-d grant_type=authorization_code \
-d code=THE_CODE \
-d redirect_uri=https://app.example.com/callback \
https://ctf.example.com/oauth/token# code_verifier = random 43-128 char string
# code_challenge = BASE64URL(SHA256(code_verifier))
GET /oauth/authorize?response_type=code
&client_id=CLIENT_ID
&redirect_uri=myapp://callback
&scope=openid%20profile
&state=RANDOM
&code_challenge=CHALLENGE
&code_challenge_method=S256
curl -d grant_type=authorization_code \
-d client_id=CLIENT_ID \
-d code=THE_CODE \
-d redirect_uri=myapp://callback \
-d code_verifier=THE_VERIFIER \
https://ctf.example.com/oauth/tokencurl -H "Authorization: Bearer ACCESS_TOKEN" \
https://ctf.example.com/oauth/userinfo
# => {"sub":"42","name":"alice","email":"alice@example.com","is_admin":false,...}- PKCE is required for public clients and supported for confidential ones.
- Redirect URIs are matched exactly.
- Access tokens default to a 1-hour lifetime; refresh tokens to 30 days.
- Token endpoints are exempt from CTFd's session CSRF (they use client auth / PKCE instead); the interactive consent form is CSRF-protected.
- Deleting an application or revoking from Authorized Apps immediately invalidates the relevant tokens.
- Trusted apps bypass user consent — only mark first-party apps you control.
is_admin/rolereflect the CTFd account type at token-issue time; relying parties should treat them as the IdP's assertion of role.- Always serve over HTTPS in production and keep the signing key persistent and secret.
- Pin
OIDC_PROVIDER_ISSUERin production. Without it the issuer is derived from the requestHostheader, which a client can spoof — affecting theid_tokenissclaim and discovery URLs. A startup warning is logged when it is unset. - Provisioning never auto-generates or logs client secrets. A confidential
app defined in the YAML file must include an explicit
client_secret(supply it from a sealed secret / vault); otherwise provisioning fails for that app. - Every application must declare at least one absolute http(s) redirect URI;
client_uriis restricted to http(s) so it can't become ajavascript:link on the consent screen.
# byte-compile sanity check
python -m py_compile *.py
# run the full local stack (CTFd + plugin + demo client)
docker compose --profile demo up --buildCI (.github/workflows/ci.yml) byte-compiles the plugin on every push/PR. The
OCI image is built and published by
.github/workflows/build-oci.yml.
Issues and pull requests welcome at https://github.com/AYDEV-FR/ctfd-oidc-provider.
Licensed under the Apache License, Version 2.0 — see LICENSE and NOTICE. This matches the license of CTFd itself.