In this post I will take you on a journey in which I migrated my netbox OIDC auth from keycloak to kanidm. Parts of this blog can be used for templating a blueprint for other migrations, client setup though differs from software to software so you’d always need to consult the documentation for whatever it is that you’re trying to integrate with OIDC.
Background
Kanidm is a modern, Rust-based identity management platform that supports OAuth2/OIDC natively. Unlike Keycloak’s heavyweight Java stack, Kanidm is lightweight and CLI-managed. That very CLI based management fits better into my workflows as it’s more focused set of capabilities. Overall I feel like it has less knobs to fiddle and better defaults than Keycloak which drove my search for alternatives. Also I wasn’t using more complex setups like specific authentication workflows.
What changes:
- Authentication Backend in netbox:
social_core.backends.keycloak.KeyCloakOAuth2will be replaced by the genericsocial_core.backends.open_id_connect.OpenIdConnectAuth - Config keys: All
SOCIAL_AUTH_KEYCLOAK_*settings are replaced withSOCIAL_AUTH_OIDC_*equivalents - Endpoints: Kanidm uses standard OIDC discovery, so we only need to provide the discovery endpoint - no separate auth/token URLs anymore
- Login button: The SSO button on the netbox login page will display a generic OIDC Reference instead of a product reference
What doesn’t change:
- netbox users, permissions and data are untouched
- The
python-social-authlibrary stays the same - only the backend class changes
Time is of the essence
At some point in time I decided to go full deny all on the local firewall for the system that hosts netbox, i.e. both incoming and outgoing packets are dropped by default. In the transition I overlooked that OpenNTPd needs to access the servers that act as constraints. This lead to the clock being unsynched and the system had a drift of almost 2s (1843ms). Compared to kanidm the system lived in the past which caused some troubles as my iat (issued at time) claim was living in the future.
So when trying to authenticate the token’s iat was checked by python’s social-auth-core module which has a 1s leeway configured in netbox/venv/lib/python3.12/site-packages/social_core/backends/open_id_connect.py:
JWT_LEEWAY: float = 1.0 # seconds
Which caused social-core to bail out with:
<class 'social_core.exceptions.AuthTokenError'>
Token error: The token is not yet valid (iat)
Took me some time to hunt down the problem even though the message was actually pretty self explanatory. At least in hindsight.
Compatibility Notes
Token signing (ES256): Kanidm defaults to ES256 (ECDSA) for JWT token signatures. The social-core OIDC backend defaults to only allowing RS256. Since social-core uses PyJWT I needed to manually add ES256 to the allowed algorithms in your netbox config:
SOCIAL_AUTH_OIDC_JWT_ALGORITHMS = ['RS256', 'ES256']
PKCE: Kanidm requires PKCE (S256) by default. The OpenIdConnectAuth backend in social-core does not implement PKCE - it extends BaseOAuth2, not BaseOAuth2PKCE, so it never sends code_challenge or code_challenge_method parameters. Kanidm will reject the authorization request with No PKCE code challenge was provided with client in enforced PKCE mode. PKCE enforcement needed to be disabled for this client:
kanidm system oauth2 warning-insecure-client-disable-pkce netbox --name idm_admin
Step 1: Create the OAuth2 Client in Kanidm
From here on I suppose netbox is up and running at https://netbox.example.com while Kanidm is reachable at https://kanidm.example.com.
Create the OAuth2 confidential client:
kanidm system oauth2 create netbox "NetBox" "https://netbox.example.com" --name idm_admin
Add the redirect URI:
The URI MUST match exactly what python-social-auth expects. The generic OIDC backend name is oidc, so the callback path is /oauth/complete/oidc/. If this doesn’t match exactly - wrong slash, wrong scheme (http vs https), wrong FQDN - you’ll get an error at Kanidm or be redirected back to netbox without being logged in.
kanidm system oauth2 add-redirect-url netbox "https://netbox.example.com/oauth/complete/oidc/" --name idm_admin
Disable PKCE:
kanidm system oauth2 warning-insecure-client-disable-pkce netbox --name idm_admin
Use short usernames (sends user instead of user@kanidm.example.com):
kanidm system oauth2 prefer-short-username netbox --name idm_admin
Step 2 : Creat a Group and Scope Mapping
Kanidm uses scope maps to control which groups can access which applications and what claims they receive.
kanidm group create netbox_users --name idm_admin
kanidm system oauth2 update-scope-map netbox netbox_users openid email profile --name idm_admin
kanidm group add-members netbox_users foobar --name idm_admin
Step 3: Retrieve the Client Secret
kandim automatically creates the secret, so we need to fetch it:
kanidm system oauth2 show-basic-secret netbox --name idm_admin
Note it down for later, treat it like a password.
Step 4: Verify the OIDC Discovery Endpoint
Before touching netbox’s config confirm the OIDC discovery endpoint is served correctly:
curl -sk https://kanidm.example.com/oauth2/openid/netbox/.well-known/openid-configuration | jq .
You should see a JSON document with authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri and the supported scopes/claims. If this doesn’t return valid JSON there’s something wrong with the Kanidm deployment.
Step 5: Update the netbox Configuration
The following configurations can be removed:
SOCIAL_AUTH_KEYCLOAK_*
LOGOUT_REDIRECT_URL
and add the following configuration:
# --- Kanidm OIDC Configuration ---
REMOTE_AUTH_ENABLED = True
REMOTE_AUTH_BACKEND = 'social_core.backends.open_id_connect.OpenIdConnectAuth'
REMOTE_AUTH_AUTO_CREATE_USER = True
REMOTE_AUTH_DEFAULT_GROUPS = [ ]
REMOTE_AUTH_DEFAULT_PERMISSIONS = {}
# OIDC settings - the generic backend auto-discovers endpoints from OIDC_ENDPOINT
SOCIAL_AUTH_OIDC_OIDC_ENDPOINT = 'https://kanidm.example.com/oauth2/openid/netbox'
SOCIAL_AUTH_OIDC_KEY = 'netbox'
SOCIAL_AUTH_OIDC_SECRET = '<secret from step 3>'
# CRITICAL: Kanidm uses ES256 by default, but social-core only allows RS256 out of the box.
# Add ES256 to the allowed algorithms so PyJWT can validate the tokens.
SOCIAL_AUTH_OIDC_JWT_ALGORITHMS = ['RS256', 'ES256']
# Force HTTPS redirects (you're behind a reverse proxy)
SOCIAL_AUTH_REDIRECT_IS_HTTPS = True
# TLS verification — this should be True in production
# If Kanidm uses a private CA, install the CA cert in NetBox's trust store instead
SOCIAL_AUTH_VERIFY_SSL = True
Important Configuration Notes
SOCIAL_AUTH_OIDC_OIDC_ENDPOINT — Note the double OIDC_OIDC in the name. This is not a typo. The setting prefix is SOCIAL_AUTH_OIDC_ (from the backend name “oidc”) and the setting itself is OIDC_ENDPOINT. The backend appends /.well-known/openid-configuration to this value automatically.
LOGOUT_REDIRECT_URL — If you had this pointed at Keycloak’s logout endpoint, remove it. Kanidm doesn’t support OIDC front-channel or back-channel logout. Either remove the setting entirely (NetBox will redirect to its own login page) or set it to your NetBox URL. Note that logging out of NetBox won’t end the Kanidm session — users clicking the OIDC button again will be logged back in without re-authenticating.
Step 6: Handle TLS Trust (Private CA)
If Kanidm uses an internal CA, netbox needs to trust it for the OIDC flow (discovery, token exchange, userinfo).
Option A: System trust store
# Linux
cp /path/to/your-ca.pem /usr/local/share/ca-certificates/your-ca.crt
update-ca-certificates
# OpenBSD
cp /path/to/your-ca.pem /etc/ssl/certs/
cat /path/to/your-ca.pem >> /etc/ssl/cert.pem
Option B: Per-request CA path
# In NetBox configuration.py
SOCIAL_AUTH_REQUESTS_EXTRA_ARGUMENTS = {
'verify': '/etc/ssl/certs/your-ca.pem'
}
Step 7: Restart NetBox and Test
# systemd
sudo systemctl restart netbox netbox-rq
# OpenBSD
doas rcctl restart netbox
- Navigate to your NetBox login page
- You should see an OpenID Connect link/button below the standard login form
- Click it — you should be redirected to Kanidm’s login page
- Authenticate with your Kanidm credentials
- On first login, Kanidm will show a consent prompt (unless you’ve disabled it)
- You should be redirected back to NetBox, logged in
Troubleshooting
Clock Skew: “Token is not yet valid” (iat or nbf)
Symptoms: social_core.exceptions.AuthTokenError: Token error: The token is not yet valid (iat) or the same error with (nbf).
Root cause: The iat (issued-at) and nbf (not-before) claims in the JWT are timestamps set by Kanidm’s clock. PyJWT validates these against the local system clock. Even 1-2 seconds of drift will cause failures because PyJWT’s tolerance is minimal (social-core 4.8.1 sets a 1-second leeway by default).
This error means the netbox host’s clock is behind Kanidm’s clock — the token appears to be issued in the future.
Diagnosis:
# Compare clocks — run from the netbox host
curl -ksI https://kanidm.example.com/status | grep -i date && date -u
If the timestamps differ by more than 1 second, you have clock skew. But be aware that HTTP Date headers only have second precision — for a more precise measurement:
python3 -c "
import requests, time
r = requests.head('https://kanidm.example.com/status', verify=False)
from email.utils import parsedate_to_datetime
server_time = parsedate_to_datetime(r.headers['date']).timestamp()
local_time = time.time()
print(f'Kanidm: {server_time}')
print(f'Local: {local_time}')
print(f'Drift: {server_time - local_time:.3f}s')
"
Fix: Sync NTP on both hosts. The fix depends on your OS:
# Linux (systemd/chrony)
timedatectl set-ntp true
chronyc makestep
# OpenBSD
ntpctl -s all # check status — look for "clock synced"
# If "clock unsynced", restart ntpd:
doas rcctl stop ntpd
doas rcctl start ntpd
# Wait for "clock synced" to appear in ntpctl -s all
VM gotcha: If either host is a VM, the guest clock can drift from the hypervisor even when NTP looks healthy on the hypervisor host. Check ntpctl -s all (OpenBSD) or chronyc tracking (Linux) inside the VM, not just on the hypervisor. I hit this exact issue — the Proxmox host showed perfect NTP sync, but the OpenBSD VM guest was 1.8 seconds behind with clock unsynced.
Temporary workaround (while fixing NTP):
# In NetBox configuration.py — REMOVE this once clocks are synced
SOCIAL_AUTH_OIDC_JWT_DECODE_OPTIONS = {
'verify_iat': False,
'verify_nbf': False,
}
“The specified alg value is not allowed”
Cause: Kanidm signs tokens with ES256 by default, but social-core only allows RS256 unless you override it.
Fix: Add ES256 to the allowed algorithms in your NetBox config:
SOCIAL_AUTH_OIDC_JWT_ALGORITHMS = ['RS256', 'ES256']
Do not downgrade Kanidm to legacy crypto (warning-enable-legacy-crypto) — PyJWT supports ES256 natively.
“No PKCE code challenge was provided” (Kanidm error)
Cause: Kanidm enforces PKCE by default, but the social-core OpenIdConnectAuth backend doesn’t implement PKCE.
Fix: Disable PKCE enforcement for this client in Kanidm:
kanidm system oauth2 warning-insecure-client-disable-pkce netbox --name idm_admin
This is acceptable for confidential clients (server-side apps with a client secret).
“Redirect URI mismatch” or “InvalidState” error at Kanidm
The redirect URI registered in Kanidm must match exactly. Verify with:
kanidm system oauth2 get netbox --name idm_admin
Check oauth2_rs_origin — it should include https://netbox.example.com/oauth/complete/oidc/ with the trailing slash. Common issues: missing slash, http vs https, wrong FQDN.
Redirected to NetBox but not logged in (no error page)
Check NetBox logs for the specific error:
# systemd
journalctl -u netbox -f
# OpenBSD
tail -f /var/log/netbox/netbox.log
Common causes:
- SSL verification failure: netbox can’t validate Kanidm’s TLS cert. Fix the CA trust (Step 6).
- Missing
cryptographypackage: PyJWT needscryptographyfor ES256. Check:pip show cryptography
Username format: user@domain instead of user
Kanidm sends the full SPN (Security Principal Name) as preferred_username by default — e.g., user@kanidm.example.com instead of just user.
Fix: Configure Kanidm to use short usernames:
kanidm system oauth2 prefer-short-username netbox --name idm_admin
Important: This only affects new tokens. If a user already logged in before this was set, their netbox account was created with the long username. You can rename it in the Django shell:
# cd /path/to/netbox && ./venv/bin/python3 manage.py nbshell
from django.contrib.auth import get_user_model
User = get_user_model()
u = User.objects.get(username='user@kanidm.example.com')
u.username = 'user'
u.save()
Existing Keycloak users can’t log in (account binding)
If users previously logged in via Keycloak, their UserSocialAuth records are associated with the keycloak backend. The new oidc backend creates separate associations. Options:
-
Clean approach: Delete old social auth associations and have users re-link
# Django shell from social_django.models import UserSocialAuth UserSocialAuth.objects.filter(provider='keycloak').delete() -
Associate by email: Add
social_core.pipeline.social_auth.associate_by_emailto the auth pipeline so existing users get matched by email address:SOCIAL_AUTH_PIPELINE = ( 'social_core.pipeline.social_auth.social_details', 'social_core.pipeline.social_auth.social_uid', 'social_core.pipeline.social_auth.social_user', 'social_core.pipeline.user.get_username', 'social_core.pipeline.social_auth.associate_by_email', 'social_core.pipeline.user.create_user', 'social_core.pipeline.social_auth.associate_user', 'social_core.pipeline.social_auth.load_extra_data', 'social_core.pipeline.user.user_details', )
Warning:
associate_by_emailtrusts that the email from the identity provider is verified. This is generally safe with Kanidm (which verifies emails) on an internal network with a single IdP, but understand the implications.
Post-Migration Cleanup
Once you’ve confirmed everything works:
- Rotate/revoke the old Keycloak client secret for the
netboxclient in Keycloak - Remove the Keycloak client if netbox was the last application using it
- Remove stale
UserSocialAuthrecords for thekeycloakprovider (see above) - Remove any
LOGOUT_REDIRECT_URLpointing at Keycloak - Disable the consent prompt in Kanidm if this is an internal deployment where consent is implied:
kanidm system oauth2 disable-consent-prompt netbox
Quick Reference: Config Mapping
| Keycloak Setting | Kanidm Equivalent |
|---|---|
SOCIAL_AUTH_KEYCLOAK_KEY |
SOCIAL_AUTH_OIDC_KEY |
SOCIAL_AUTH_KEYCLOAK_SECRET |
SOCIAL_AUTH_OIDC_SECRET |
SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL |
Auto-discovered via SOCIAL_AUTH_OIDC_OIDC_ENDPOINT |
SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL |
Auto-discovered via SOCIAL_AUTH_OIDC_OIDC_ENDPOINT |
SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY |
Not needed — JWKS auto-discovered |
| (not needed with Keycloak backend) | SOCIAL_AUTH_OIDC_JWT_ALGORITHMS = ['RS256', 'ES256'] |
Backend: keycloak |
Backend: oidc |
Redirect path: /oauth/complete/keycloak/ |
Redirect path: /oauth/complete/oidc/ |
Kanidm Client Configuration (for reference)
The final Kanidm client configuration should look like this:
displayname: NetBox
name: netbox
oauth2_allow_insecure_client_disable_pkce: true
oauth2_jwt_legacy_crypto_enable: false
oauth2_prefer_short_username: true
oauth2_rs_origin: https://netbox.example.com/oauth/complete/oidc/
oauth2_rs_origin_landing: https://netbox.example.com/
oauth2_rs_scope_map: netbox_users@kanidm.example.com: {"email", "openid", "profile"}
Verify with: kanidm system oauth2 get netbox --name idm_admin
Kanidm OIDC Endpoints (for debugging)
All endpoints are auto-discovered via the OIDC discovery document, but for manual debugging:
| Endpoint | URL |
|---|---|
| Discovery | https://kanidm.example.com/oauth2/openid/netbox/.well-known/openid-configuration |
| Authorization | https://kanidm.example.com/ui/oauth2 |
| Token | https://kanidm.example.com/oauth2/token |
| Userinfo | https://kanidm.example.com/oauth2/openid/netbox/userinfo |
| JWKS | https://kanidm.example.com/oauth2/openid/netbox/public_key.jwk |