DEV Community

Cover image for Auto-assigning a role to self-registered users in Zitadel (with a Zitadel Action)

Auto-assigning a role to self-registered users in Zitadel (with a Zitadel Action)

Intro

I recently set up self-registration in Zitadel for a side project and ran into a classic chicken-and-egg problem:

  • Self-registered users land in the system with zero project grants.
  • Without a grant, my backend rejects them because they have no role claim.
  • The Management API call that would grant them a role requires a token they don't have.

The fix is a Zitadel Action that runs server-side, on the right trigger, using a service-account PAT to grant the role automatically. Easy in theory — but it took a few iterations to get right, so here's the recipe and the traps.

Stack Context

The Three Pieces

1. Enable self-registration on The Login Policy

By default, the "Register" link is hidden on the Zitadel login UI. Flip it on by updating the org's login policy with allowRegister: true. You can see on how you can configure this in through the admin panel as explained here.

Code: zitadel-management-v1.service.jsenableSelfRegistration().

2. Create a Zitadel Action on The Right Trigger

This is the part where you must understand Zitadel's Actions v1 flow/trigger matrix. Some of the relevant combinations:

Flow Trigger When it fires
2 — Complement Token 4 — Pre Userinfo Creation Before the userinfo response is built
2 — Complement Token 5 — Pre Access Token Creation Before the access token is built
1 — External Auth 1/2/3 — Post Auth / Pre Creation / Post Creation External IdP flows
3 — Internal Auth 1/2/3 — Post Auth / Pre Creation / Post Creation Username/password flows
4 — Complement SAML 6 — Pre SAML Response Creation SAML responses

Ref to flow types & trigger types.

I picked Flow 2 / Trigger 4 (Complement Token → Pre Userinfo Creation). It fires on every token issuance, so even if I miss the user during registration itself, the first time they log in their grant is created before any client sees their claims.

⚠️ NOTE

  • This is still not ideal if you ask me. We must add the claim on user registration. But also keep in mind that we only grant the role once and that would be the first time that user self-register and then get a JWT token.
  • allowedToFail: false — if the grant call fails, fail the token issuance. Better to block login than to issue a token for a user with no role.
Logger.section('Enabling Self-Registration');
await managementV1Service.enableSelfRegistration();

Logger.section('Setting Up Auto Role Assignment Action');
const actionScript = buildPostRegistrationActionScript(
  projectId,
  accessToken,
);
const actionId = await managementV1Service.createAction(
  'postSelfRegistrationToExtendUserClaims',
  actionScript,
  5,
  false, // allowedToFail: false — block registration if this fails
);
await managementV1Service.setTriggerActions('2', '4', [actionId]);
Enter fullscreen mode Exit fullscreen mode

Code: local-setup/setup-zitadel/src/main.js

3. The Action Script Itself

Actions run inside Zitadel's own process in a sandboxed JS runtime. In my JS scripts you can see I am using zitadel/http, and zitadel/log:

const http = require('zitadel/http');
const logger = require('zitadel/log');

// @ts-check

/**
 * @param {object} grantsInfo
 * @param {number} [grantsInfo.count] - Total number of grants (may be inconsistent with "grants.length" in some runtimes).
 * @param {Array<{roles?: string[]}|null|undefined>} [grantsInfo.grants] - List of grants; each grant may include a "roles" array.
 * @param {string} roleKey - The role key to look for (e.g. "user").
 * @returns {boolean}
 */
function hasRole(grantsInfo, roleKey) {
  if (!grantsInfo || !grantsInfo.grants || grantsInfo.count <= 0) {
    return false;
  }

  for (let i = 0; i < grantsInfo.grants.length; i++) {
    const g = grantsInfo.grants[i];
    if (!g || !g.roles) continue;

    for (let j = 0; j < g.roles.length; j++) {
      if (g.roles[j] === roleKey) return true;
    }
  }

  return false;
}

/**
 * @param {object} ctx
 * @param {object} ctx.v1
 * @param {() => {id: string}} ctx.v1.getUser
 * @param {object} ctx.v1.user
 * @param {function} ctx.v1.user.grants
 * @param {function} ctx.v1.user.getMetadata
 * @param {object} ctx.v1.org
 * @param {function} ctx.v1.org.getMetadata
 * @param {object} ctx.v1.claims
 * @param {function} ctx.v1.claims.sub
 * @param {object} ctx.v1.application
 * @param {function} ctx.v1.application.getClientId
 * @param {object} api
 * @param {object} api.v1
 * @param {object} api.v1.user
 * @param {function} api.v1.user.setMetadata
 * @param {object} api.v1.claims
 * @param {function} api.v1.claims.setClaim
 * @param {function} api.v1.claims.appendLogIntoClaims
 * @param {object} api.v1.userinfo
 * @param {function} api.v1.userinfo.setClaim
 * @param {function} api.v1.userinfo.appendLogIntoClaims
 * @returns {void}
 */
function postSelfRegistrationToExtendUserClaims(ctx, api) {
  const user = ctx.v1.getUser();
  const grantsInfo = ctx.v1.user && ctx.v1.user.grants; // 👈 If ZITADEL populated grants in this action trigger, use it.

  logger.log('Auto-granting user role to self-registered user: ' + user.id);

  if (hasRole(grantsInfo, 'user')) {
    return;
  }

  let response = http.fetch('http://traefik:80/management/v1/users/' + user.id + '/grants', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ${botPat}', // 👈 Do I need this since this is happening in internal authentication flow.
      'Content-Type': 'application/json',
    },
    body: {
      projectId: '${projectId}',
      roleKeys: ['user'],
    },
  });

  if (response.status >= 200 && response.status < 300) {
    logger.log('Successfully granted user role to user ' + user.id);
    return;
  };

  logger.log('Failed to grant role. Status: ' + response.status + ' Body: ' + response.body);
};
Enter fullscreen mode Exit fullscreen mode
  • zitadel/httpfetch-like client.
  • zitadel/log — writes into Zitadel's structured logs.

The action checks whether the user already has the user role (idempotency — this trigger fires on every token issuance), and if not, calls the Management API to grant it. The script is built at setup time by interpolating the projectId and a service-account PAT.

Code: post-self-registration-to-extend-user-claims.mjs.

The gotchas

This is the part I wish someone had told me.

Post Registration Action

  1. The function name MUST match the action name exactly.

    If you register the action as postSelfRegistrationToExtendUserClaims but the function in the script body is called something else, Zitadel reports "function not found" and silently skips the action.

  2. You cannot use the registering user's token.

    We cannot use ctx.v1.authToken (ctx) for the freshly self-registered users to grant them "user" role. Thus the need for a service-account PAT with enough rights on the Management API.

  3. The host name is the Docker service name.

    This one cost me real time. Because the action runs inside Zitadel's own process — not on the host, not in a browser — it talks to the internal Docker network. In my setup that means http://traefik:80/management/v1/..., not https://localhost:8080/....

  4. Clear the browser's localStorage when changing token's shape.

    When I switched the app from opaque access tokens to JWT access tokens, my frontend kept presenting the old token from localStorage and the backend kept rejecting it. The action and Zitadel config were fine — the UI was just caching the wrong token. Clearing localStorage (or doing a silent re-auth) solves it instantly.

  5. ctx.v1.user.grants may or may not be populated.

    The Zitadel docs don't make crystal-clear whether ctx.v1.user.grants is populated on every trigger. I treat it defensively (ctx.v1.user && ctx.v1.user.grants) and use it for the idempotency check; the worst case is one redundant POST that returns a "grant already exists" error, which is fine.

Self-registration flow

Code: zitadel-auth.provider.ts.


GitHub

https://github.com/kasir-barati/smart-novel/tree/5cb5ca8753a1b7e59790c1c508fe8947cdca1f30

Top comments (0)