Advanced Session Management with Zitadel Actions V2

  1. Step 1-Deploy your target’s code
  2. Step 2-Create your Target
  3. Step 3 - Create your Action
  4. Step 4 - Test logging in with a sample user and validate the number of sessions
  5. Conclusion
  6. How can I try out and contribute to Zitadel?
  7. Suggested reading

Zitadel Actions offer a powerful way to extend and tailor the behavior of the Zitadel identity platform to meet the specific needs of your organization. While Zitadel provides secure, standards-compliant authentication and user management out of the box, real-world applications often require custom logic, and that’s where Actions come into play. With Actions, you can inject dynamic behavior into key identity lifecycle processes such as user login, registration, or session handling. This enables you to validate inputs, enrich user profiles, call external APIs, enforce complex business rules, or trigger automations, all without modifying the core platform.

In this guide, we’ll demonstrate how to use Zitadel Actions V2 to implement advanced session management by enforcing a limit on the number of concurrent sessions a user can have. By leveraging the /zitadel.session.v2.SessionService/CreateSession method, we'll track active sessions in real-time and ensure users do not exceed a predefined session cap.

Whether you're building a multi-device login system or want tighter control over authentication behavior, this guide walks you through a powerful use of Zitadel’s extensibility.

Step 1-Deploy your target’s code

Note- A self-hosted or cloud instance of Zitadel is a pre-requisite before you get started.

To deploy this code, you can utilize your existing infrastructure or opt for a free hosting service, such as Render or Vercel. This simple Express API handles webhook events from Zitadel when a new session is created.

It performs the following steps:

  • Validates the request signature using the HMAC-based signature header provided by Zitadel, ensuring the request is authentic and unmodified.
  • Extracts the user ID from the incoming event payload.
  • Checks the total number of active sessions for the user.
  • If the user has more sessions than the allowed limit (e.g., more than 2), it:
    • Keeps only the most recent sessions.
    • Deletes the oldest ones to enforce the session limit.

This setup allows for session control and security by enforcing session limits per user. Sample code (be aware that this code may not be production-ready):

const express = require('express');
const morgan = require('morgan');
const axios = require("axios");
const crypto = require('crypto');
require('dotenv').config();
const app = express();
const port = 3000;
app.use(express.json({
   verify: (req, res, buf) => {
       req.rawBody = buf.toString('utf8');
   }
}));
app.use(morgan('tiny'));
app.post('/session/manage', async (req, res) => {
   // Get the webhook signature
   const signatureHeader = req.headers['zitadel-signature'];
   if (!signatureHeader) {
       console.error("Missing signature");
       return res.status(400).send('Missing signature');
   }
   // Validate the webhook signature
   const SIGNING_SECRET = process.env.SIGNING_KEY;
   const elements = signatureHeader.split(',');
   const timestamp = elements.find(e => e.startsWith('t=')).split('=')[1];
   const signature = elements.find(e => e.startsWith('v1=')).split('=')[1];
   const signedPayload = `${timestamp}.${req.rawBody}`;
   const hmac = crypto.createHmac('sha256', SIGNING_SECRET)
       .update(signedPayload)
       .digest('hex');
   const isValid = crypto.timingSafeEqual(
       Buffer.from(hmac),
       Buffer.from(signature)
   );
   if (!isValid) {
       console.error("Invalid signature");
       return res.status(400).send('Invalid signature');
   }
   // Machine User PAT
   const PAT = process.env.PAT;
   // Max. number of concurrent sessions per user
   const CS = process.env.CONCURRENT_SESSIONS;
   // Zitadel Instance URL
   const instanceURL = process.env.INSTANCE_URL;
   const body = req.body;
   // User ID of the user that just logged in
   const userID = body.request.checks.user.userId
   // Request used to query the sessions for that userID
   let data = {
       "queries": [
           {
               "userIdQuery": {
                   "id": `${userID}`
               }
           }
       ]
   };
   try {
       const response = await axios.post(`${instanceURL}/v2/sessions/search`, data, {
           headers: {
               'Content-Type': 'application/json',
               'Authorization': `Bearer ${PAT}`
           }
       });
       // Sort sessions by creation date and grab only the fields I need
       const sortedSessions = response.data.sessions.sort((a, b) => {
           return new Date(b.creationDate) - new Date(a.creationDate);
       });
       // Check if there are more than X concurrent sessions
       if (sortedSessions.length > CS) {
           // Only the X most recent sessions will remain
           const sessionsToDelete = sortedSessions.slice(CS);
           for (const session of sessionsToDelete) {
               console.log(`Deleting session ID: ${session.id}`);
               await axios.delete(`${instanceURL}/v2/sessions/${session.id}`, {
                   headers: {
                       Authorization: `Bearer ${PAT}`,
                       'Content-Type': 'application/json'
                   }
               });
           }
       }
   } catch (error) {
       console.error('Error:', error.response?.data || error.message);
   }
   res.status(200).send('OK');
});
// Start the server and listen on the specified port
app.listen(port, () => {
   console.log(`Server listening at http://localhost:${port}`);
});
  • To run this code, you will need to set the following environment variables:

    • PAT: An IAM_OWNER Personal Access Token to call the Zitadel API.
    • CONCURRENT_SESSIONS: The maximum number of concurrent sessions per user.
    • INSTANCE_URL: Your Zitadel instance URL.
    • SIGNING_KEY=Your Target’s signing key, obtained in the next step
  • You can modify this code to use another method to authenticate the service user instead of a PAT, for instance, client_credentials. You will be able to set the SIGNING_KEY after you create the target in the next step. At the time of writing this blog post, it is possible to create targets and V2 actions using the APIs directly.

Note: Action will be moved to General Availability with the Zitadel V4 release.

Step 2-Create your Target

  • To create a target, use the following cURL command:
curl -L -X POST '<INSTANCE_URL>/v2beta/actions/targets' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <TOKEN>' \
-d '{
    "name": "<TARGET_NAME>",
    "restAsync": {
        "interruptOnError": false
    },
    "endpoint": "<TARGET_URL>",
    "timeout": "10s"
}'

  • where these variables mean the following:

    • INSTANCE_URL: The URL where your Zitadel instance is hosted, either cloud or self hosted.
    • TOKEN: A token with Admin permissions, can be the same PAT used in the previous step.
    • TARGET_NAME: Pick a meaningful name for your Target.
    • TARGET_URL: The webhook URL that will be POSTed by Zitadel, this points to the API you deployed in the previous step.
  • As a response, you will receive something like this:

{
   "id": "<TARGET_ID>",
   "creationDate": "2025-07-18T11:12:47.998576Z",
   "signingKey": "<SIGNING_KEY>"
}
  • You will need the TARGET_ID in the next step, and SIGNING_KEY as the environment variable for the code you deployed in step 1.

Step 3 - Create your Action

  • To create the Action, use the following cURL command:
curl -L -X PUT '<INSTANCE_URL>/v2beta/actions/executions' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <TOKEN>' \
-d '{
    "condition": {
        "request": {
            "method": "/zitadel.session.v2.SessionService/CreateSession"
        }
    },
    "targets": ["<TARGET_ID>"]
}'
  • where these variables mean the following:

    • INSTANCE_URL: The URL where your instance is hosted, either cloud or self-hosted.
    • TARGET_ID: The ID of the target created in the previous step. TOKEN: A token with Admin permissions can be the same PAT used in the previous step.

Step 4 - Test logging in with a sample user and validate the number of sessions

  • To get the list of sessions for a user, use the following cURL command:
curl -L -X POST '<INSTANCE_URL>/v2/sessions/search' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <TOKEN>' \
-d '{
  "queries": [
    {
      "userIdQuery": {
        "id": "<USER_ID>"
      }
    }
  ]
}'

Note- Sessions may be invalid because the session cookie expired (or cookies were deleted), therefore, the session stored by Zitadel cannot be referenced anymore by the client application (the browser). This means that those sessions cannot be used, or simply expired, but that is something that cannot be determined by Zitadel.

Conclusion

This approach provides a simple yet effective way to manage concurrent sessions using Zitadel Actions V2. By enforcing session limits, you gain better control, enhanced security, and a foundation for more advanced session management features.To continue exploring Zitadel Actions, you could create one to get current sessions inside a custom claim in the user token, so users can manage their sessions on your application.

How can I try out and contribute to Zitadel?

Read our documentation and learn how you can set up, customise, and integrate authentication and authorisation into your project. We would love to see you being a part of the Zitadel community, and you can reach us at

Suggested reading

Liked it? Share it!