Custom OIDC claims allow more granular control as we can give each client application its own scope with custom claims. In this article we’ll use Authelia backed by LLDAP as an Identity Provider (IdP) to implement custom claims for Argo CD and Audiobookshelf .
Motivation#
Although not part of the list
of standard OIDC claims
,
the groups
claim is often used for user permissions in a client
— e.g. if a given user has the groups: [ 'admin' ]
claim,
it will have admin-privileges for the given client, assuming that’s how it’s configured.
This is all well and good if you only have one application, or if every user is supposed to have the same privileges for every client, though that is rarely the case.
An alternative is to prefix each attribute with the corresponding client
— e.g. argocd:admin
, audiobookshelf:admin
, usw.
,
though then every client will be able to see the user’s privileges in every other client as well,
which is not ideal.
Building on the notion that the groups
claim is already non-standard,
we can instead introduce our own non-standard claim
—
so-called custom claims
,
for each application.
Separate scopes for each application enable us to isolate each claim for the intended client only,
this way we can request an argocd
or audiobookshelf
scope instead of the groups
scope.
Prerequisites#
This article assumes a functioning implementation of Authelia using LLDAP as an authentication backend — a somewhat esoteric choice perhaps, though the concepts should hopefully be readily applicable to other OIDC providers and authentication backends. If you’re interested in following along to the letter, I’ve written both an article for getting started with LLDAP , as well as an article for configuring Authelia as an OIDC provider .
Implicitly, this article also assumes a working Kubernetes cluster, though LLDAP, Authelia, and Audiobookshelf can all be run without Kubernetes.
Overview#
We’ll first configure custom attributes in LLDAP. Following Infrastructure as Code (IaC) principles, we’ll use the community-maintained bootstrapping script to provision users and custom attributes, though you could also ClickOps the configuration.
After bootstrapping the LLDAP instance, we’ll look at how we can configure Authelia to pick up the custom attributes from LLDAP and expose them as claims for clients to consume.
Lastly, we’ll configure Argo CD and Audiobookshelf to make use of our OIDC expansion.
LLDAP#
To get started with LLDAP in your Kubernetes cluster, you can peruse the following article
LLDAP v0.6 — released in November 2024, or newer is required for custom attributes. The bootstrapping script got updated in April 2025 — in pull request #1155 , which at the time of writing is not part of a stable release. We therefore have to use a development version to be able to bootstrap values for the custom attributes.
We can carefully craft either a ConfigMap or Secret to create the required JSON files for the bootstrapping process. I prefer to use Secrets for this and encrypt it using sealed-secrets — or something similar, to keep any Personal Identifiable Information (PII) private.
The following Secret will expand into three JSON files:
groups.json
(line 8) which will create a regular group we can assign to a useruser-schemas.json
(line 12) which is a custom attribute we will later turn into a claimusers.json
(line 29) which creates two users and assigns attributes to them
|
|
We’ve applied the custom attributes to both the admin
user (line 36–38) and the user
user (line 46–47).
The lldap_admin
role is a built-in role in LLDAP.
Note also that the group and user configurations can either
— curiously enough
,
be a single JSON file with nested JSON top-level values or several JSON files,
not a JSON list as with the user- and group-schema configuration.
The bootstrapping script requires admin-credentials,
which — if you followed the LLDAP configuration in my previous article,
can be found in the admin-credentials
Secret
|
|
We also need to tell the bootstrapping script where to find LLDAP,
which we can do by providing it with a LLDAP_URL
env variable,
again assuming you followed by LLDAP guide this can be set to http://lldap:80
as in
the Kustomize
configMapGenerator
below on lines 5–11
|
|
For a declarative configuration we’ve also told the script to clean up any discrepancies between the actual and desired
configuration by setting DO_CLEANUP=true
on line 11.
Next, we stitch everything together using a Kubernetes
Job
.
Here we’ve chosen a fairly new image (line 16) which includes the updated bootstrap.sh
script (line 17) which lets us
bootstrap custom attributes.
Also note the annotations on lines 8 and 9 which allows Argo CD to relaunch the job on every app sync action.
For basic config we’re importing the Kustomize generated ConfigMap on line 19,
though we have to transform the admin-credentials from env-variables understood by LLDAP to env-variables picked up by
bootstrap.sh
on line 23 and 26.
|
|
Next we volume-mount the different JSON configuration files from the lldap-config
Secret on lines 29–31 and 39, 45,
and 51.
Applying the above manifests and launching the Job should configure your LLDAP deployment with our custom attributes.
For more details regarding the LLDAP bootstrapping process,
see the documentation
.
Authelia#
Authelia v4.39 released in March 2025 did a major overhaul on the use of ID Tokens and Claims Policies, as well as adding support for Custom Scopes and Claims. I recommend reading James Elliott’s OpenID Connect 1.0 Nuances article for more details on the changes.
I’ve previously written a comprehensive guide to getting started with Authelia, so I’ll assume you’ve either read it or have a similar setup as described there and take some shortcuts.
We first need to tell Authelia to pick up the custom attributes from LLDAP by explicitly requesting them as extra attributes using the following configuration
|
|
Next we need to configure claims_policies
to expose the extra attributes as claims
|
|
Notice that we also configure the ID Token to contain the requested claims (line 29) for Argo CD as Argo CD only
fetches the groups
claim from
the UserInfo Endpoint
.
I’ve opened an issue with the Argo CD project asking them for general support of the UserInfo Endpoint
in Issue #23768
.
After exposing the attributes as claims, we need to create custom scopes to allow requesting said claims
|
|
Finally, we need to configure the clients claim_policy
to one that includes the custom claims (line 42 and 55,
argocd_policy
and audiobookshelf
respectively),
as well as allowing the custom scopes that include the claims (line 47 and 61, argocd_scope
and audiobookshelf
respectively).
|
|
Custom description (optional)#
It’s possible to add a description to our custom Claims and Scopes by overriding server assets . To do this, we can create a ConfigMap containing our overrides by again using Kustomize’s configMapGenerator
|
|
which references our own version of the consent.json
locale file based
on the original
,
but with our custom Claims
|
|
and Scopes added
|
|
Before configuring Authelia to accept our asset override (line 15) and mounting the custom file (lines 4–6)
|
|
Note that we’re only overriding the English locale here as I’m assuming it’s used as a fallback.
Clients#
With the IdP bit configured, we can now configure clients to make use of the custom claims. Due to what I can only assume is a complicated specification, both the Identity Provider and Client side implementation of the OAuth/OIDC spec vary between providers and clients. I’ve therefore picked Argo CD and Audiobookshelf to display some of the different client implementations.
Argo CD#
Argo CD has pretty decent OAuth/OIDC support,
although it appears to only honour the groups
claim from the UserInfo Endpoint,
which is why we configured Authelia to allow more claims in the ID Token.
Ideally, Argo CD should fetch user claims from the UserInfo Endpoint using an Access Token instead of relying on the
claims being present in the ID Token.
I’ve opened Issue #23768
for the Argo CD developer to hopefully look
into this.
Argo CD also appears to not support the Authorization Code Flow with Proof of Key for Code Exchange (PKCE), throwing the following error if we try
Error occurred: Error getting token Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). The request was determined to be using ’token_endpoint_auth_method’ method ’none’, however the OAuth 2.0 client registration does not allow this method.
This suggests that Argo CD only supports PKCE as a so-called public client.
We therefore rely on only the client_secret
for authentication,
skipping the extra security PKCE offers.
I’ll be paying attention to Issue #23773
for if/when they decide to
implement Authorization Code Flow with both a client secret and PKCE.
What Argo CD gets right, however,
is the option to request optional,
or non-essential claims like name
and email
.
The following Argo CD Helm chart values configure Authelia OIDC integration.
Note the essential useless
—since we’re not using the groups
claim,
parameters on line 16–18 to fetch the groups
claim from Authelia’s UserInfo Endpoint.
|
|
Also note the discrepancy on line 21 where what Argo CD calls a scope
is actually a claim
,
Scopes being a group of Claims
.
I’ve opened Issue #23772
to further investigate this.
Configuring Argo CD with the above configuration and trying to log in, you should be met with the following consent screen


Deselecting all the checkboxes and inspecting the ID Token, you should see the following claims missing
{
"email": "[email protected]",
"name": "Admin",
"preferred_username": "admin"
}
For more details configuring OIDC in Argo CD, consult the operator manual on RBAC .
Audiobookshelf#
Audiobookshelf honours the use of the UserInfo Endpoint to request user claims, even custom ones, though we have to configure it using traditional ClickOps following their OIDC guide .
Since I can’t provide you with a piece of declarative configuration, I’ll have to rely on a screenshot on how I’ve configured Authelia as an OIDC provider in Audiobookshelf.
I’ve opened Issue #4479 with Audiobookshelf asking for the feasibility of adding declarative configuration.
Having configured OIDC, you should now be met with a login screen similar to the one below


Inspecting the openid_id_token
cookie after logging in we see that it only contains the openid
scope claims,
indicating that Audiobookshelf is using the UserInfo Endpoint for additional properties.
Audiobookshelf also supports a custom claim for advanced permissions for non-admin users which we will not look into here. Instead of a list as with the group claim, the advanced permission claim expects a JSON object with the following structure:
{
"canDownload": false,
"canUpload": false,
"canDelete": false,
"canUpdate": false,
"canAccessExplicitContent": false,
"canAccessAllLibraries": false,
"canAccessAllTags": false,
"canCreateEReader": false,
"tagsAreDenylist": false,
"allowedLibraries": [
"5406ba8a-16e1-451d-96d7-4931b0a0d966",
"918fd848-7c1d-4a02-818a-847435a879ca"
],
"allowedTags": [
"ExampleTag",
"AnotherTag",
"ThirdTag"
]
}
If an option is missing, it will be treated as false
.
Implementing the advanced permission claim is left as an exercise to the reader. Submissions in the comments.
While playing around, I also noticed an issue where Audiobookshelf appears to cache the public JWK for JWT validation where I had to restart Audiobookshelf before it accepted new keys. This could be a user error, though I opened Issue #4478 asking about it.
Summary#
All resources shown in this article can be found in the resources folder of the article . Where applicable, they are written for use with Argo CD with Kustomize + Helm , but should be easily adaptable for other approaches using e.g. Flux CD .
The resources in this article are not intended to be applied directly, but rather incorporated in an existing configuration. I invite you to explore my homelab repository to view a functioning in situ configuration at the time of writing this article (commit f5841b4 ), especially the k8s/infra/auth directory .
LLDAP#
# lldap/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
configMapGenerator:
- name: bootstrap-env
namespace: lldap
literals:
- TZ="Europe/Oslo"
- LLDAP_URL="http://lldap:80"
- DO_CLEANUP="true"
resources:
- lldap-config.yaml
- admin-credentials.yaml
- bootstrap.yaml
# lldap/admin-credentials.yaml
apiVersion: v1
kind: Secret
metadata:
name: admin-credentials
namespace: lldap
stringData:
LLDAP_LDAP_USER_DN: "admin"
LLDAP_LDAP_USER_PASS: "<RANDOM_PASSWORD>"
# lldap/lldap-config.yaml
apiVersion: v1
kind: Secret
metadata:
name: lldap-config
namespace: lldap
stringData:
groups.json: |
{
"name": "k8s:cluster_admin"
}
user-schemas.json: |
[
{
"name": "argocd",
"attributeType": "STRING",
"isEditable": true,
"isList": true,
"isVisible": true
},
{
"name": "audiobookshelf",
"attributeType": "STRING",
"isEditable": true,
"isList": true,
"isVisible": true
}
]
users.json: |
{
"id": "admin",
"email": "[email protected]",
"firstName": "Admin",
"lastName": "Adminsdottir",
"displayName": "admin",
"groups": [ "lldap_admin", "k8s:cluster_admin" ],
"argocd": [ "admin" ]
"audiobookshelf": [ "admin" ],
}
{
"id": "user",
"email": "[email protected]",
"firstName": "User",
"lastName": "Userson",
"displayName": "user",
"argocd": [ "reader" ]
"audiobookshelf": [ "user" ]
}
# lldap/bootstrap.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: lldap-bootstrap
namespace: lldap
annotations:
argocd.argoproj.io/hook: PostSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: lldap-bootstrap
image: ghcr.io/lldap/lldap:2025-07-10-alpine-rootless
command: [ /app/bootstrap.sh ]
envFrom:
- configMapRef: { name: bootstrap-env }
env:
- name: LLDAP_ADMIN_USERNAME
valueFrom:
secretKeyRef: { name: admin-credentials, key: LLDAP_LDAP_USER_DN }
- name: LLDAP_ADMIN_PASSWORD
valueFrom:
secretKeyRef: { name: admin-credentials, key: LLDAP_LDAP_USER_PASS }
volumeMounts:
- { name: tmp, mountPath: /tmp }
- { name: groups, mountPath: /bootstrap/group-configs, readOnly: true }
- { name: user-schemas, mountPath: /bootstrap/user-schemas, readOnly: true }
- { name: users, mountPath: /bootstrap/user-configs, readOnly: true }
volumes:
- { name: tmp, emptyDir: { } }
- name: groups
projected:
sources:
- secret:
name: lldap-config
items: [ { key: groups.json, path: groups.json } ]
- name: user-schemas
projected:
sources:
- secret:
name: lldap-config
items: [ { key: user-schemas.json, path: user-schemas.json } ]
- name: users
projected:
sources:
- secret:
name: lldap-config
items: [ { key: users.json, path: users.json } ]
Authelia#
# authelia/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: authelia
configMapGenerator:
- name: consent
namespace: authelia
files:
- ./locales/en/consent.json
resources:
- client-secret-argocd.yaml
- client-secret-audiobookshelf.yaml
helmCharts:
- name: authelia
repo: https://charts.authelia.com
releaseName: authelia
namespace: authelia
version: 0.10.34
valuesFile: values.yaml
# authelia/client-secret-argocd.yaml
apiVersion: v1
kind: Secret
metadata:
name: client-argocd
namespace: authelia
stringData:
clientSecret: '<HASHED_ARGO_CD_CLIENT_SECRET>'
# authelia/client-secret-audiobookshelf.yaml
apiVersion: v1
kind: Secret
metadata:
name: client-audiobookshelf
namespace: authelia
stringData:
clientSecret: '<HASHED_AUDIOBOOKSHELF_CLIENT_SECRET>'
# authelia/values.yaml
pod:
extraVolumeMounts:
- name: consent
mountPath: /config/assets/locales/en/consent.json
subPath: consent.json
extraVolumes:
- name: consent
configMap:
defaultMode: 0644
name: consent
configMap:
server:
asset_path: /config/assets/
authentication_backend:
ldap:
attributes:
extra:
argocd: { multi_valued: true, value_type: string }
audiobookshelf: { multi_valued: true, value_type: string }
identity_providers:
oidc:
claims_policies:
argocd_policy:
custom_claims: { argocd_claim: { attribute: argocd } }
id_token: [ email, name, preferred_username, argocd_claim ]
audiobookshelf:
custom_claims: { audiobookshelf: { attribute: audiobookshelf } }
scopes:
argocd:
claims: [ argocd_claim ]
audiobookshelf:
claims: [ audiobookshelf_claim ]
clients:
- client_id: argocd
client_secret: { path: /secrets/client-argocd/client_secret.txt }
client_name: Argo CD
public: false
claims_policy: argocd_policy
require_pkce: false
redirect_uris:
- https://argocd.example.com/auth/callback
- https://argocd.example.com/applications
scopes: [ openid, email, profile, offline_access, argocd_scope ]
grant_types: [ authorization_code, refresh_token ]
userinfo_signed_response_alg: none
- client_id: audiobookshelf
client_secret: { path: /secrets/client-audiobookshelf/client_secret.txt }
client_name: Audiobookshelf
public: false
claims_policy: audiobookshelf
require_pkce: true
redirect_uris:
- https://abs.example.com/audiobookshelf/auth/openid/callback
- https://abs.example.com/audiobookshelf/auth/openid/mobile-redirect
- audiobookshelf://oauth
scopes: [ openid, email, profile, offline_access, audiobookshelf ]
grant_types: [ authorization_code, refresh_token ]
secret:
additionalSecrets:
authelia-postgres-app:
client-argocd:
items: [ { key: clientSecret, path: client_secret.txt } ]
client-audiobookshelf:
items: [ { key: clientSecret, path: client_secret.txt } ]
Copied from Authelia GitHub repository — which is subject to change without notice, and added description for custom Claims and Scopes.
{
"Accept this consent request": "Accept this consent request",
"An error occurred processing the request": "An error occurred processing the request",
"Claim": "Claim {{name}}",
"Client ID": "Client ID: {{client_id}}",
"Code": "Code",
"Confirm the Code": "Confirm the Code",
"Consent has been accepted and processed": "Consent has been accepted and processed",
"Consent has been rejected and processed": "Consent has been rejected and processed",
"Consent Request": "Consent Request",
"Debug Information": "Debug Information",
"Deny this consent request": "Deny this consent request",
"Description": "Description",
"Documentation": "Documentation",
"Error": "Error",
"Error Code": "Error Code",
"Failed to submit the user code": "Failed to submit the user code",
"Hint": "Hint",
"Remember Consent": "Remember Consent",
"Scope": "Scope {{name}}",
"The above application is requesting the following permissions": "The above application is requesting the following permissions",
"This saves this consent as a pre-configured consent for future use": "This saves this consent as a pre-configured consent for future use",
"You may close this tab or return home by clicking the home button": "You may close this tab or return home by clicking the home button",
"You must reauthenticate to be able to give consent": "You must reauthenticate to be able to give consent",
"claims": {
"address": "Postal Address",
"birthdate": "Birthdate",
"email": "E-mail Address",
"email_verified": "E-mail Address (Verified)",
"family_name": "Family Name",
"gender": "Gender",
"given_name": "Given Name",
"groups": "Group Membership",
"locale": "Locale / Language",
"middle_name": "Middle Name",
"name": "Display Name",
"nickname": "Nickname",
"phone_number": "Phone Number",
"phone_number_verified": "Phone Number (Verified)",
"picture": "Picture URL",
"preferred_username": "Preferred Username",
"profile": "Profile URL",
"sub": "Unique Identifier",
"updated_at": "Profile Update Time",
"website": "Website URL",
"zoneinfo": "Timezone",
"argocd_claim": "Argo CD Membership",
"audiobookshelf": "Audiobookshelf Membership"
},
"scopes": {
"address": "Access your address",
"authelia.bearer.authz": "Access protected resources logged in as you",
"email": "Access your email addresses",
"groups": "Access your group memberships",
"offline_access": "Automatically refresh these permissions without user interaction",
"openid": "Use OpenID to verify your identity",
"phone": "Access your phone number",
"profile": "Access your profile information",
"argocd_scope": "Access Argo CD memberships",
"audiobookshelf": "Access Audiobookshelf memberships"
}
}
Argo CD#
# argocd/oidc-client-secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: oidc
namespace: argocd
labels:
app.kubernetes.io/part-of: argocd
stringData:
authelia.clientSecret: '<ARGO_CD_CLIENT_SECRET>'
# argocd/values.yaml
configs:
cm:
oidc.config: |
name: 'Authelia'
issuer: 'https://authelia.example.com'
clientID: 'argocd'
clientSecret: $oidc:authelia.clientSecret
cliClientID: 'argocd-cli'
requestedScopes: [ 'openid', 'offline_access' ]
requestedIDTokenClaims:
argocd_claim: { essential: true }
email: { essential: false }
name: { essential: false }
preferred_username: { essential: false }
enableUserInfoGroups: true
userInfoPath: /api/oidc/userinfo
userInfoCacheExpiration: '5m'
rbac:
scopes: '[ argocd_claim ]'
policy.csv: |
g, admin, role:admin
g, read, role:readonly