OIDC with PKCE and LDAP groups for internal tools

Most internal tools need the same three things: sign-in through the corporate identity provider, roles that follow directory groups, and access that ends when someone leaves. This guide covers how those pieces fit together.

Which protocol does what

OIDC and LDAP are often presented as alternatives. In a good design, they do different jobs.

Concern Best handled by Why
Authenticating a user OIDC The password and MFA stay with the identity provider, and your tool never sees them
Single sign-on across tools OIDC The session lives at the provider
Group membership at login OIDC claims Arrives in the token, with no extra connection
Group data when the user isn't present LDAP sync Team pages, assignment lists and pre-provisioning need it
Direct login for tools with no OIDC support LDAP bind A fallback. The tool handles the password, so it's a weaker design

So use OIDC for sign-in, and LDAP (or SCIM, if your provider offers it) when your tool needs directory data outside of a login event.

The authorization code flow with PKCE

PKCE, defined in RFC 7636, protects the authorization code. Without it, anybody who captures the code from a redirect could exchange it for tokens. With it, the client proves that it started the flow.

The steps:

  1. The client generates a random code_verifier and stores it in the user's session.
  2. It computes code_challenge = BASE64URL(SHA256(code_verifier)).
  3. It redirects the browser to the provider's authorization endpoint with the challenge.
  4. The user authenticates, and the provider redirects back with a short-lived code.
  5. The client sends the code and the original code_verifier to the token endpoint.
  6. The provider hashes the verifier, compares it with the stored challenge, and returns tokens if they match.

You can see the calculation in a shell. A verifier is 43 to 128 characters from a limited unreserved set:

verifier=$(openssl rand -base64 64 | tr -d '\n=' | tr '+/' '-_' | cut -c1-64)
challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary \
  | openssl base64 -A | tr -d '=' | tr '+/' '-_')

As a check on your own code, the RFC's example verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk must produce the challenge E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM.

The authorization request looks like this, with line breaks added for reading:

GET https://idp.example/authorize
  ?response_type=code
  &client_id=internal-tool
  &redirect_uri=https%3A%2F%2Ftool.example%2Fcallback
  &scope=openid%20profile%20email
  &state=af0ifjsldkj
  &nonce=n-0S6_WzA2Mj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

And the token exchange:

curl -s https://idp.example/token \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri=https://tool.example/callback \
  -d client_id=internal-tool \
  -d code_verifier="$VERIFIER"

Find the real endpoint addresses in /.well-known/openid-configuration on your issuer, and avoid hard-coding them. Always use S256, because the plain method gives no protection against someone who can read the request.

PKCE is not only for public clients

PKCE was designed for mobile and browser apps, which can't keep a client secret. Current guidance in RFC 9700, the OAuth security best practice document, recommends it for all clients using the code flow, including those with a server-side secret, as it also defends against code injection. If your tool has a backend, use both: a client secret and PKCE.

The checks that are easy to get wrong

A login that works isn't necessarily a secure one. Before trusting an ID token, your code or library has to verify these:

  • Signature, using a key from the provider's JWKS endpoint. Fix the permitted algorithms in your configuration. Don't accept whichever algorithm the token header names.
  • **iss** exactly matches your expected issuer.
  • **aud** includes your client ID. Otherwise a token issued for a different application would work on yours.
  • **exp**, with minimal clock skew allowance.
  • **nonce** matches what you sent, which prevents replay.

Separately, state must match the value bound to the user's session. It stops login CSRF. Treat these as tests to write, rather than assumptions: try a token with the wrong audience, and one with the wrong issuer, and confirm that both fail. Use a well-maintained OIDC library. Don't write a validator yourself.

Other points that commonly come up in security reviews:

  • Exact redirect URIs. Register complete URIs, with no wildcards. Open redirects on your domain combined with loose matching can leak codes.
  • Tokens stay out of the browser where possible. For a server-rendered or backend-for-frontend tool, keep tokens server-side, and give the browser a session cookie marked HttpOnly, Secure and SameSite.
  • Don't log tokens or codes. Include proxies and access logs in that check. Query strings carrying code values often end up in logs.
  • TLS everywhere. A closed network is no excuse. Private CAs mean your application must trust the right bundle, which is a common first-day failure on internal deployments. Keep those certificates under expiry monitoring too.

Mapping groups to roles

Directory groups say who someone is. Application roles say what they can do. Keep a mapping layer between them, so a directory reorganisation doesn't require code changes.

role_mappings:
  - group: "CN=sre-oncall,OU=Groups,DC=corp,DC=example"
    role: operator
    scope: { environments: [prod, preprod] }
  - group: "CN=payments-dev,OU=Groups,DC=corp,DC=example"
    role: viewer
    scope: { teams: [payments], environments: [prod] }
  - group: "CN=payments-dev,OU=Groups,DC=corp,DC=example"
    role: operator
    scope: { teams: [payments], environments: [preprod] }
  - group: "CN=tool-admins,OU=Groups,DC=corp,DC=example"
    role: admin

This example's format is illustrative. Three design rules apply:

  • Default deny. A user with no mapped group gets no access, not a read-only view of everything.
  • Scope matters as much as role. "Viewer" of what? In a monitoring tool, a developer on one team may not be permitted to see another team's logs, which can contain customer data.
  • Use stable identifiers. Use a group's immutable ID where the provider gives you one, because names can change, and a recycled name can grant access to the wrong people.

Getting groups into the token

Usually you configure the provider to issue a groups claim, sometimes tied to a custom scope. Be aware of size limits. A user in hundreds of groups can produce a token too large for an HTTP header, and some providers respond by leaving groups out and giving a reference that your application must look up. Filter the claim to groups relevant to the application, if your provider allows it. Also decide how to handle a token with a missing claim: fail closed and log it clearly.

LDAP sync, and its traps

When you sync from a directory, use a read-only service account limited to the required subtree, over LDAPS or with StartTLS:

ldapsearch -H ldaps://ldap.corp.example:636 \
  -D "CN=svc-tool-reader,OU=Service,DC=corp,DC=example" -W \
  -b "OU=Groups,DC=corp,DC=example" -E pr=500/noprompt \
  "(&(objectClass=group)(cn=tool-*))" cn member

Some recurring traps:

  • Nested groups. member often only lists direct members, while people assume nesting works. Some directory servers offer a transitive matching rule, others need recursive lookups. Check your directory's documentation and test it with a nested group.
  • Paging. Large directories limit the results per query. Without paged results, the sync silently returns a partial list, and users can lose access.
  • Empty results. Treat an unexpectedly empty result as an error. If an expired service account password returns nothing and your sync then removes all memberships, you've locked everybody out. Set a limit on how much a single sync may remove.
  • Empty password binds. If your tool performs an LDAP bind for login, reject empty passwords in your code. RFC 4513 describes the unauthenticated bind, where an empty password can succeed with no real authentication.
  • Filter injection. Escape any user input placed into an LDAP filter, as characters like * and ( change its meaning.

Offboarding: how long does access last?

Ask this question explicitly, because auditors will. When someone is disabled in the directory, what happens to a session already open?

The answer has several parts: access token lifetime, refresh token behaviour, your application's own session length, and the LDAP sync interval. As an example, with a 10-minute access token, a refresh attempt that fails once the account is disabled, and a 15-minute sync, access ends in at most about 15 minutes, provided your app checks token status regularly. A separate 8-hour session cookie that never rechecks could keep someone in for a full working day. Test this with a real account rather than reasoning about it.

OIDC defines Back-Channel Logout, which lets a provider notify applications directly, though support varies. Group removal has a similar lag, because groups in an ID token remain as they were at issue time. Sensitive actions should recheck current permissions.

Break-glass access

A monitoring tool is needed the most when things are broken, and an identity provider outage may be one of those times. Plan a local emergency account:

  • A long random credential, held in a sealed or vaulted process
  • Disabled for routine use, with a test on a schedule
  • Any use fires a high-priority alert and goes in the audit log
  • Rotated after each use

Audit logging

Log logins, failed logins, role changes, mapping changes and administrative actions, along with who, what, when and from where. Never log tokens or passwords. Ship these logs to a store that administrators of the tool itself can't alter. Rules such as DORA, PCI DSS and KVKK can bear on access control and logging in general terms, and what they require of you depends on your situation. This is not legal advice, so involve your compliance team.

How Wrentrix does access

Wrentrix's access control supports OIDC SSO with PKCE and LDAP sync. Team-based RBAC scopes views according to mappings from LDAP or SSO groups, and an audit log records every change. It runs on your own OpenShift or Kubernetes cluster or Linux servers. Ask us about specific identity provider or directory details for your setup.

Frequently asked questions

What is PKCE and why is it needed?

PKCE stands for Proof Key for Code Exchange. The client sends a hash of a random secret when starting the login and reveals the secret when exchanging the code, so a stolen code alone is useless. It's now recommended for all OAuth clients that use the authorization code flow, including those with client secrets.

Should an internal tool use OIDC or LDAP for login?

Prefer OIDC, because the tool never handles passwords and it inherits your identity provider's MFA and session policies. LDAP bind for login means the tool receives the user's password, which increases risk. LDAP remains useful for syncing group and user data in the background.

How do I map LDAP groups to application roles?

Keep a configuration layer that maps group identifiers to roles and scopes, and default to no access when no group matches. Use stable group IDs where available, since names may change. Test nested groups explicitly, because many directory queries return direct members only.

Why are groups missing from my ID token?

The provider usually needs explicit configuration to issue a groups claim, and sometimes a specific scope. Users with many groups may also exceed a size limit, which leads some providers to omit the claim and supply a reference for lookup instead. Fail closed when the claim is absent, and filter it down to the relevant groups.

How quickly should access end when an employee leaves?

Set a target with your security team, then test it with a real disabled account. The effective time is determined by token lifetimes, refresh behaviour, application session length and directory sync frequency together. Long-lived application sessions that never recheck the account are the most common gap.