The form supplied defaults my script never sent

This is my homelab: single-node Kubernetes clusters, one operator, and Authentik as the identity provider in front of the self-hosted apps. Nothing here describes an employer's systems.
Authentik ships a shell that sits directly on its database models. With no API token to hand,
ak shell is the quickest way to make an OAuth2 provider: a few lines to create it, a few more to
bind an application, nothing to click. That is how the provider for the lab's Nextcloud went in,
and every login against it failed.
The assumption underneath is that an object made by a script and the same object made in the admin UI are the same object. They are not. The admin form is a key-cutting counter: you say which door, and it cuts the parts of the profile you never think about. A script hands you the blank. It slides into the lock and turns nothing.
Read the refusal where it was written
The login came back with invalid_request and a description saying the request was "otherwise
malformed". That names no field, and a malformed request sends you off to inspect the request,
which was fine. The server is more specific than the browser:
kubectl -n authentik logs -l app.kubernetes.io/component=server --tail=200 | grep -i grant_type
# "Invalid grant_type for provider" is the provider refusing a flow it was never given
That warning comes from one membership test in the authorisation endpoint: is this request's grant type in the provider's list? On the scripted provider the list was not wrong. It was empty, and against an empty list the answer is always no.
The form cuts what your script never names
Open Authentik's admin form for a new OAuth2 provider and the grant-type boxes arrive already
ticked, with an invalidation flow already chosen. You give it a name, pick an authorisation flow and
enter a redirect URI, and the form submits the rest. Those defaults live in the form's own code.
ak shell writes to the model underneath, which takes the keyword arguments you passed and leaves
everything else at the model's own default.
So the scripted provider did get defaults, just not the form's. The model makes a new provider a confidential client, which is harmless, and gives it an empty grant-type list, which permits nothing.
This was the second time the shell had done it. Earlier the same month a proxy provider made the
same way came out with empty redirect URIs, and every login landed on the identity provider's own
homepage; that one has its own post. The proxy provider carries a
helper for exactly this job, set_oauth_defaults(), and the lesson I banked was always call the
helper. The plain OAuth2 provider has no such helper, so the lesson had nothing to hold on to. Its
authorisation flow, invalidation flow and signing key go in by hand, or the provider is half-formed.
Its redirect URIs carry a trap of their own: the field wants the model's RedirectURI dataclass,
and a plain string fails with an asdict() error.
The scripted object looks right, which is what makes this expensive. Every field you supplied is there: a name, a client ID, a redirect URI. The fields you did not supply stay invisible until the protocol tries them at login.
Compare with a key that turns, then cut your own
The fix that worked in July was plain. The lab already had providers the form had made, working since June, so the scripted one got a working provider's grant types copied onto it. With that and two unrelated fixes on Nextcloud's side of the handshake, calendar SSO went live.
That worked because the two apps wanted the same grants. It is not a method. Two applications can legitimately need different grant types, client types, redirect URIs, flows and scope mappings, and copying a working provider wholesale can hand the target permissions it never needed. Use the working object to find the questions you forgot to ask, and read only the fields you mean to compare:
# inside ak shell on the authentik server; reads named fields only, changes nothing
from authentik.providers.oauth2.models import OAuth2Provider
made = OAuth2Provider.objects.get(name="nextcloud")
proven = OAuth2Provider.objects.get(name="gitea")
FIELDS = ["client_type", "grant_types", "authorization_flow_id", "invalidation_flow_id",
"signing_key_id", "sub_mode", "issuer_mode", "include_claims_in_id_token"]
for name in FIELDS:
a, b = getattr(made, name), getattr(proven, name)
print(name, "same" if a == b else f"{a!r} | {b!r}", sep=": ")
print("redirect URIs set:", bool(made.redirect_uris), bool(proven.redirect_uris))
print("scope mappings:", sorted(made.property_mappings.values_list("name", flat=True)))
# each difference is a question for the target app, not an answer to copy
The answers come from the app in front of you, each with a reason: the authorisation-code grant, plus refresh only if the app uses it; a confidential client; the one redirect URI the app calls back to; the flows; a signing key; the scope mappings the app actually reads. Write that list down and set every item in the script. Anything that skips the form should be assumed to get the model's empty list unless it names the grant types itself. And a provider that saves is not a login that works: a full sign-in, a token refresh if the app relies on one, and a logout are separate checks.
The probe I would normally reach for next has blind spots on exactly this object. Posting a dummy code to the token endpoint is the usual way to test a client's credentials apart from the app. Read the credentials so they stay out of shell history and the process list, and bound the call:
read -rp 'client id: ' CLIENT_ID
read -rsp 'client secret: ' CLIENT_SECRET; echo
printf 'user = "%s:%s"\n' "$CLIENT_ID" "$CLIENT_SECRET" |
curl -sS --max-time 10 -K - -X POST https://authentik.example.org/application/o/token/ \
-d grant_type=authorization_code -d code=not-a-real-code \
-d redirect_uri=https://calendar.example.org/apps/user_oidc/code
# the error code alone does not say which check failed; the server's log line for this request does
In the 2026.5.3 token view
the checks run in a fixed order. An unknown client ID is invalid_client. A grant type missing from
the provider's list is invalid_grant. A confidential client with the wrong secret is
invalid_client, and a public client skips the secret check entirely. Only then, for a code grant, an
unregistered redirect URI is invalid_client and an unknown code is invalid_grant.
So invalid_grant is what a right secret earns, and also what a blank grant list or a public client
earns. invalid_client can mean a wrong secret, an unknown client or a redirect URI the provider does
not know. The server log names the branch (Invalid grant_type for provider, Invalid client secret, a configuration event for the redirect URI, Code does not exist), and only the last says
the request got as far as checking the code. Even that is not a working login.
The principle
Treat every admin form as part of the schema: the boxes that arrive ticked, the choices made on your behalf, the fields it will not submit without. Nothing promises that any of it lives in the model, and none of it travels with a script. Automate underneath the form and you inherit its job, including the parts nobody wrote down. An object the form already made is the best checklist of that job you will find, and it is a checklist, not a template.
Walk past the counter and you are cutting keys by hand. A key that already turns shows you which cuts exist; the lock you are cutting for decides each one.
From the lab, July 2026, on Authentik 2026.5.3: the Nextcloud provider made in ak shell refused
every authorisation-code login until a working provider's grant types were copied onto it, and
calendar SSO went live through it on 2026-07-20. The token-view order above was read from the
2026.5.3 source, which is still the installed version on 30 September 2026.

