SCIM Provisioning Between Authentik and Self-Hosted Apps: Tested Patterns That Actually Work

Managing users across a self-hosted stack is one of those problems that looks trivial until it isn’t. You add someone to Authentik, they get SSO access — great. But their Gitea account still needs to be created manually. Their Nextcloud quota needs setting. Their Mattermost username doesn’t match. Then they leave the company and you spend 40 minutes hunting down every app they had access to.

SCIM fixes this. The protocol has existed since 2015, the standard is solid, and Authentik has had a production-quality SCIM provider since version 2023.4. Yet most people running Authentik still manually manage users in downstream apps because the setup is underdocumented and the gotchas are not obvious.

This article is the guide I wish existed when I first set this up. It covers the architecture, the working configurations for several common self-hosted apps, and the failure modes you will hit if you skip the details.


What SCIM Actually Does (and Doesn’t Do)

SCIM (System for Cross-domain Identity Management, RFC 7644) is a REST API specification for synchronizing identity data between systems. The identity provider — Authentik, in our case — is the provisioner. The downstream app is the consumer. The provisioner pushes user and group objects to the consumer whenever something changes.

This is not SSO. SCIM and SAML/OIDC solve different problems and you typically run them together. OIDC handles authentication (proving who you are), SCIM handles provisioning (making sure the account exists and has the right attributes before you authenticate). If you only set up OIDC, the app creates user accounts on first login but can’t pre-create them, can’t deprovision them on deletion, and can’t sync group memberships proactively.

SCIM gives you:

  • Automatic account creation before the user ever logs in
  • Real-time attribute sync (display name, email, department)
  • Group membership sync
  • Account deactivation (and optionally deletion) when users are removed or disabled in Authentik

What it does NOT give you:

  • Password sync (passwords live in Authentik, downstream apps authenticate via OIDC/SAML)
  • Fine-grained per-attribute access policies in most apps — the consumer takes what the provisioner sends

Authentik’s SCIM Provider Architecture

Authentik’s official GitHub: https://github.com/goauthentik/authentik

In Authentik, SCIM is an outbound sync provider. You configure it under Applications → Providers → Create → SCIM Provider. Unlike SAML or OIDC providers which are passive and respond to auth requests, the SCIM provider actively pushes changes.

The flow looks like this:

[Authentik User/Group change]
         ↓
   SCIM Provider (outbound)
         ↓
   HTTP PATCH/POST/DELETE
         ↓
   Downstream App SCIM endpoint

Authentik triggers a sync in three situations:

  1. Manual full sync from the UI or API
  2. Scheduled periodic sync (configurable, default 60 minutes)
  3. Real-time signal on user/group create, update, delete

The real-time signal is what makes this actually useful. Within seconds of disabling a user in Authentik, the SCIM provider will fire a PATCH to every connected app to mark that user inactive.


Core Configuration in Authentik

Before touching any downstream app, get the Authentik side clean.

Step 1: Create a SCIM Provider

Go to Admin → Providers → Create, select SCIM Provider.

Key fields:

  • Name: something descriptive, like scim-gitea — keep one provider per app
  • URL: the SCIM endpoint of your downstream app (format varies per app, covered below)
  • Token: a bearer token generated by the downstream app
  • User filtering: an Authentik group filter — critical for not dumping your entire user directory into every app

The group filter is the most important setting most people ignore. In Property Mappings, Authentik lets you restrict which users and groups get synced. Do this from day one. Syncing your entire Authentik user base into a project management tool is a support ticket waiting to happen.

# Property mapping to filter users — only sync members of a specific group
# Go to: Customisation → Property Mappings → Create → SCIM Mapping

# User filter expression (Python, evaluated per user)
return ak_is_group_member(request.user, name="your-app-users")

Step 2: Bind the Provider to an Application

A SCIM provider must be bound to an Authentik application. Create (or reuse) the app that represents the downstream service. Under Application → Edit → Backchannel Providers, add your SCIM provider.

This binding tells Authentik which application "owns" this SCIM sync, which matters for audit logs and for the real-time signal hooks.

Step 3: Trigger an Initial Full Sync

After saving, go to the provider detail page and hit Run sync manually. Watch the sync status — it will show created/updated/failed counts. Do this before testing login. If the initial sync fails, fix it before adding OIDC on top or you’ll be debugging two things at once.


Tested App Configurations

Gitea / Forgejo

Both Gitea and Forgejo support SCIM 2.0 via the scim package, available since Gitea 1.21. It’s one of the better implementations in the self-hosted ecosystem.

Enable SCIM in Gitea:

# app.ini — add to [scim] section
[scim]
ENABLED = true

Then generate a token:

# Gitea CLI or via API — create an admin token with scim scope
gitea admin user generate-access-token \
  --username admin \
  --token-name authentik-scim \
  --raw

Authentik SCIM provider settings for Gitea:

URL: https://gitea.yourdomain.com/api/v1/scim/v2
Token: <token from above>

Gotcha — Gitea’s SCIM endpoint requires trailing slash sensitivity. The URL must end without a trailing slash. Some Authentik versions append one; if you see 404 errors in sync logs, check this first.

Gotcha — username collisions. If a user already exists in Gitea (created manually before you set up SCIM), the sync will fail for that user with a conflict error. Either delete the manual accounts first or reconcile them. Gitea matches on userName (the Authentik username), not email.

For Forgejo, the setup is identical — Forgejo maintains API compatibility with Gitea here.


Nextcloud

Nextcloud has SCIM support via the user_scim app. The built-in user_ldap/user_saml apps do NOT do SCIM — you need the separate community app.

# Install the SCIM app
docker exec -u www-data nextcloud php occ app:install user_scim

Generate a bearer token inside Nextcloud:

docker exec -u www-data nextcloud php occ user_scim:token:add authentik-scim

Authentik SCIM provider settings:

URL: https://nextcloud.yourdomain.com/index.php/apps/user_scim/v2
Token: <token from occ command>

Gotcha — group provisioning in Nextcloud requires manual group creation first. The SCIM app will sync users into groups, but it will not create groups that don’t exist yet. Create the target groups in Nextcloud before running the first sync, or it will silently drop group membership assignments.

Gotcha — quota is not a SCIM attribute. You cannot set storage quota via SCIM. You’ll still need either a default quota policy or post-provisioning automation for that.

Production tip: Pair SCIM provisioning with Nextcloud’s user_saml for authentication. SCIM creates the account and syncs attributes; SAML handles login. They operate independently and don’t interfere.


Mattermost

Mattermost has first-class SCIM support but it’s gated behind the Enterprise plan for the official implementation. However, there’s a workaround: Mattermost has a local SCIM endpoint available at /api/v4/scim that’s accessible even on the free tier if you configure it correctly.

// mattermost config.json — relevant section
{
  "ServiceSettings": {
    "EnableAPIv4": true
  },
  "ExperimentalSettings": {
    "EnableSharedChannels": false
  }
}

For the free tier, you need to enable the endpoint via an environment variable or system console toggle. Check your Mattermost version — this changed between 8.x and 9.x.

Authentik SCIM provider settings:

URL: https://mattermost.yourdomain.com/api/v4/scim/v2
Token: <Mattermost personal access token with system admin role>

Gotcha — Mattermost deactivates rather than deletes users by SCIM spec. When Authentik sends a DELETE, Mattermost marks the account inactive. This is correct behavior, but the user still occupies a license seat in some configurations. If you’re running a seat-limited deployment, audit deactivated accounts periodically.


Outline (Wiki)

Outline supports SCIM natively since v0.65. It’s one of the cleaner implementations — the endpoint is well-documented and the behavior is predictable.

# In Outline's .env or docker-compose environment
SCIM_ENABLED=true
SCIM_SECRET=your-secret-token-here

Authentik SCIM provider settings:

URL: https://outline.yourdomain.com/api/scim/v2
Token: <value of SCIM_SECRET>

Gotcha — Outline maps SCIM groups to Outline Collections, not to user roles. If you’re expecting SCIM groups to control admin access, they won’t — role assignment is separate. Use Authentik’s SAML/OIDC attributes for that.


A Working Docker Compose Reference

Here’s a minimal reference setup showing how Authentik sits in a compose stack alongside a generic app with SCIM configured:

# docker-compose.yml — Authentik + SCIM-enabled app skeleton
version: "3.9"

services:
  authentik-server:
    image: ghcr.io/goauthentik/server:2024.6.0
    command: server
    environment:
      AUTHENTIK_REDIS__HOST: redis
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
      AUTHENTIK_SECRET_KEY: ${SECRET_KEY}
      # SCIM sync runs in the worker, not the server — both need the same config
    volumes:
      - ./media:/media
      - ./custom-templates:/templates
    ports:
      - "9000:9000"

  authentik-worker:
    image: ghcr.io/goauthentik/server:2024.6.0
    command: worker
    environment:
      AUTHENTIK_REDIS__HOST: redis
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
      AUTHENTIK_SECRET_KEY: ${SECRET_KEY}
    # Worker handles outbound SCIM pushes — always run at least one worker
    volumes:
      - ./media:/media

  postgresql:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: authentik
      POSTGRES_USER: authentik
      POSTGRES_PASSWORD: ${PG_PASS}
    volumes:
      - pg_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

volumes:
  pg_data:
  redis_data:

The worker is not optional. SCIM sync tasks are queued through Celery and executed by the worker process. If you only run authentik-server, your SCIM provider will appear configured but nothing will ever sync.


Gotchas Summary

1. The worker must be running. SCIM syncs are background tasks. No worker = no sync. This causes a lot of "I set it up and nothing happened" confusion.

2. SCIM and OIDC use separate attribute mappings. Changing a SAML/OIDC property mapping does not affect what SCIM sends. Update both if you add a new attribute.

3. Initial full sync is not automatic. After creating a SCIM provider, you must manually trigger the first sync. Subsequent syncs (real-time + periodic) work automatically, but Authentik won’t backfill existing users without that first manual run.

4. Deletion vs. deactivation is app-specific. When a user is deleted in Authentik, the SCIM spec allows either DELETE or PATCH-to-inactive. Most apps implement deactivation, not deletion. Audit this behavior for each app and document it for your operations team.

5. Username immutability. SCIM sends a stable externalId (Authentik’s user UUID) to identify users across renames. Most apps respect this — but some poorly-implemented SCIM consumers match on username only. If a user renames in Authentik, those apps will create a duplicate account instead of updating the existing one.

6. Group sync is a separate mapping. You can sync users without syncing groups, and vice versa. If group membership isn’t propagating, check that you have both a user mapping and a group mapping configured on the provider, and that the groups themselves pass your filter.


Production-Ready Patterns

Use one SCIM provider per application. Don’t try to reuse a single provider across multiple apps. Each provider has its own endpoint, token, and filter logic. Keeping them separate makes debugging and audit logging vastly easier.

Filter aggressively. Define an Authentik group for each app (app-gitea-users, app-nextcloud-users, etc.) and use those as SCIM sync filters. This gives you clean access control and prevents accidents when onboarding bulk users.

Monitor sync failures. Authentik logs SCIM sync results under Events → Logs with type system_task_finished. Set up a log export or alert on sync failures — a broken SCIM sync is silent until someone notices their account doesn’t exist.

Test deprovisioning before you rely on it. Create a test user in Authentik, sync them to your target app, then disable the user in Authentik and verify the account becomes inactive in the app within a few minutes. Do this during setup, not when you’re offboarding an employee under time pressure.

Keep Authentik updated. SCIM support has improved significantly across minor versions. Several attribute-sync bugs were fixed between 2023.4 and 2024.x. Running outdated Authentik specifically to "keep things stable" is a bad trade here — the SCIM-related fixes are worth the upgrade.


Verifying a Sync Manually

When something isn’t working, go direct to the SCIM API before blaming Authentik. Here’s how to test a downstream app’s SCIM endpoint manually:

# Test that the SCIM endpoint is reachable and authenticated
curl -s \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/scim+json" \
  "https://yourapp.yourdomain.com/scim/v2/Users" \
  | jq '.totalResults, .Resources[0].userName'

# Check if a specific user exists (replace with the Authentik UUID)
curl -s \
  -H "Authorization: Bearer YOUR_TOKEN" \
  "https://yourapp.yourdomain.com/scim/v2/Users?filter=externalId+eq+%22AUTHENTIK-UUID%22" \
  | jq '.'

If that curl returns data cleanly, the app is ready to receive pushes. If it 401s or 404s, fix the endpoint or token before touching Authentik.

On the Authentik side, after triggering a sync:

# Check recent system task logs via Authentik API
curl -s \
  -H "Authorization: Bearer YOUR_AUTHENTIK_API_TOKEN" \
  "https://authentik.yourdomain.com/api/v3/events/system_tasks/?ordering=-finish_timestamp&page_size=10" \
  | jq '.results[] | {task_name, status, finish_timestamp}'

Where to Go From Here

Once SCIM provisioning is running, the next logical step is attribute enrichment — pushing richer user metadata (department, title, manager) from Authentik into apps that support it. Authentik’s property mappings let you pull from LDAP attributes, custom user fields, or group metadata.

The other natural extension is lifecycle automation: when a user’s account is set to expire in Authentik (hire-date-based deprovisioning), SCIM will automatically cascade that expiry to every connected app. Combined with an HRIS webhook feeding Authentik, you get near-zero-touch onboarding and offboarding across your entire self-hosted stack.

That’s the end goal — not "less manual work in some apps," but actually closing the loop on identity lifecycle across everything you run.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646