Skip to content
Scalekit Docs
Talk to an Engineer Dashboard

Intercept authentication flows

Run your own checks during sign-up, login, invitations and token issuance, and allow, deny, choose the organization or add claims

Interceptors let your app take part in Scalekit’s authentication flows. When a user signs up or logs in, when someone is invited, or when a service requests a machine-to-machine token, Scalekit calls an endpoint in your app and waits for its decision. Your endpoint can allow or deny the step and, at some trigger points, choose the user’s organization or add claims to their tokens.

A sales CRM, for example, uses interceptors to log a consultant straight in to the customer workspace they opened, and to add a new sales rep to their company’s existing workspace instead of creating a duplicate.

This page covers the points you can intercept, how to build and register the endpoint, and what each trigger point sends and accepts.

A trigger point is a step in a Scalekit flow where your interceptor runs, and each interceptor you register listens on one trigger point. Trigger points labeled Authentication flow run while a user signs up or logs in. The names match the ones the dashboard shows.

Trigger pointWhen it runsYour response can
Post-user authentication Authentication flowAfter the user proves their identity, before Scalekit provisions them or shows the organization switcherDeny the login, or choose the organization the user logs in to
Pre-signup Authentication flowWhen a user signs up and would create a new organizationDeny the sign-up, or add the user to an existing organization instead
Pre-session creation Authentication flowAt the end of every login, before session tokens are issuedDeny the login, or add custom claims to the access token
Pre-user invitationBefore a user is added to an organization from the dashboard or the APIDeny the invitation
Pre-M2M token creationBefore a machine-to-machine access token is issuedDeny the token, or add custom claims to it

The authentication flow trigger points run when users sign in on Scalekit’s hosted login page in an environment with Full Stack Auth, whatever the method: magic link or OTP, passkey, social login or enterprise SSO. They also run for MCP servers whose users sign in on that page. They do not run for Modular SSO integrations, or for MCP servers that send users to your own login service (custom auth), because Scalekit does not run its login steps there.

During one login they run in this order:

During a login, Scalekit calls your endpoint at up to three points and waits on each answer

Whichever trigger point you pick, your endpoint follows the same contract. Scalekit sends a JSON body and three headers: interceptor-id, interceptor-timestamp and interceptor-signature. Your endpoint verifies the signature, makes its decision and replies with HTTP 200 and a JSON body.

The request body has the same four top-level fields at every trigger point:

FieldWhat it holds
display_nameThe name you gave the interceptor
trigger_pointThe trigger point, for example POST_USER_AUTHENTICATION
interceptor_contextWho and where: environment, user ID and email, organization, connection, IP address, device and the authorization request
dataThe objects the decision is about: the user and their memberships, the organization, or the M2M token claims

Each trigger point below shows its full request.

The signature covers the raw request body, so verify before you parse the JSON. Copy the signing secret from the Interceptors page in the dashboard and keep it in an environment variable.

Express.js
import express from 'express';
import { ScalekitClient } from '@scalekit-sdk/node';
const app = express();
const scalekit = new ScalekitClient(
process.env.SCALEKIT_ENVIRONMENT_URL,
process.env.SCALEKIT_CLIENT_ID,
process.env.SCALEKIT_CLIENT_SECRET
);
// Verify every interceptor route against the raw body, then hand handlers the parsed JSON
function verifyInterceptor(req, res, next) {
const rawBody = req.body.toString('utf8');
try {
scalekit.verifyInterceptorPayload(process.env.SCALEKIT_INTERCEPTOR_SECRET, req.headers, rawBody);
} catch (error) {
console.error('Interceptor verification failed:', error);
return res.json({ decision: 'DENY', error: { message: 'Unable to process request.' } });
}
req.body = JSON.parse(rawBody);
next();
}
app.use('/auth/interceptors', express.raw({ type: 'application/json' }), verifyInterceptor);
FieldRequiredWhat it does
decisionYesALLOW continues the flow. DENY stops it.
error.messageNoWith DENY, the message the user sees. Without it, Scalekit shows a generic message.
responseNoWith ALLOW, the change the trigger point allows: the organization, or the claims. Each trigger point below lists its fields.

Reply with HTTP 200 even when you deny. Scalekit treats any other status, or no reply within the timeout, as a failure, not as a decision.

Once the endpoint runs, register it in the dashboard and send it a test request.

  1. In the Scalekit dashboard, go to Interceptors and add an interceptor. Enter a name, pick the trigger point, and enter your endpoint’s HTTPS URL.

    Interceptors settings in the Scalekit dashboard

  2. Set the timeout, from 1 to 30 seconds (5 by default), and the failure policy: Fail open continues the flow when your endpoint fails, Fail closed stops it.

  3. Click Create, then turn on Enable.

  4. Open the Test tab and click Send request. Scalekit sends a sample request for the trigger point to your endpoint and shows your response next to it.

    Interceptor test tab example

  5. Open the logs to see each request Scalekit sent and the response or error it got back. Requests from the Test tab appear there too.

    Interceptor logs in the dashboard

Your endpoint fails when it returns a status other than 2xx, or does not reply within the timeout. The failure policy decides what happens next:

PolicyWhat Scalekit does
Fail openContinues as if you had returned ALLOW with no response. The Test tab still shows the failure.
Fail closedStops the flow and shows the generic message.

The generic message is “The request could not be completed due to a policy restriction. Please contact support for assistance.” When the environment has a support email, the message names it: “Please contact support@yourapp.com for assistance.” Scalekit also shows it when you deny without an error.message.

Each trigger point below covers when it runs, what Scalekit sends, what your endpoint can return, and an example.

Authentication flow

Post-user authentication runs once per login, after the user proves their identity and before Scalekit provisions them or shows the organization switcher. It runs for new users too: the request then has no user_id and no data.user.

Use it to block a login, or to choose the organization the user logs in to. The fields that drive that choice:

FieldWhat it holds
interceptor_context.auth_requestParameters from the authorization URL: client_id, plus organization_id, connection_id, domain and login_hint when your app or the login page set them
data.user.membershipsEach organization the user belongs to, with its status and roles
interceptor_context.connection_detailsThe method the user signed in with
Request body
{
"display_name": "Route users to their workspace",
"trigger_point": "POST_USER_AUTHENTICATION",
"interceptor_context": {
"environment_id": "env_92561807201272162",
"user_id": "usr_93418238346728951", // Only when the user already exists
"user_email": "john.doe@example.com",
"auth_request": {
"client_id": "skc_92561807201272100",
"organization_id": "org_93418204671239865", // Only when your app passed it
"connection_id": "conn_92561808744978132",
"login_hint": "john.doe@example.com",
"state": "xsrPHl7k7ARgdhC6"
},
9 collapsed lines
"connection_details": [
{ "id": "conn_92561808744978132", "type": "PASSWORDLESS", "provider": "SCALEKIT" }
],
"device_type": "Desktop",
"ip_address": "203.0.113.24",
"region": "US",
"city": "San Francisco",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36...",
"triggered_at": "2026-10-13T09:48:02.875Z"
},
"data": {
"user": { // Only when the user already exists
"id": "usr_93418238346728951",
"email": "john.doe@example.com",
"memberships": [
{ "organization_id": "org_93418204671239864", "organization_name": "Northwind", "status": "ACTIVE", "roles": ["admin"] },
{ "organization_id": "org_93418204671239865", "organization_name": "Globex", "status": "ACTIVE", "roles": ["member"] }
],
6 collapsed lines
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"email_verified": true,
"created_at": "2025-10-06T11:06:49.120Z",
"updated_at": "2025-10-06T13:33:06.479Z"
}
}
}

Return ALLOW with no response to continue as usual: a user with several organizations sees the organization switcher. To choose the organization yourself, send one of these two fields. Either one skips the switcher.

FieldWhat Scalekit does
select_organizationLogs the user in to an organization they already belong to, including one whose invitation is pending or has expired.
create_organization_membershipAdds the user to the organization, then logs them in. Creates the user first if they are new, uses an active or pending membership if one exists, and re-activates an inactive, deleted or expired one.
organization_idScalekit’s ID for the organization, such as org_93418204671239865. Send this or external_organization_id in either object. If you send both, Scalekit uses organization_id.
external_organization_idYour own ID for the organization, the external ID you set on it in Scalekit. Scalekit uses it only when organization_id is absent.
create_organization_membership.rolesOptional role names for a new membership. An existing or re-activated membership keeps its roles, and names that match no role are ignored.

Each response this trigger point accepts:

Response: continue as usual
{
"decision": "ALLOW"
}

create_organization_membership skips the organization’s allowed email domains, so your endpoint alone decides who joins. If the chosen organization requires SSO, Scalekit sends the user to its identity provider before finishing the login.

John belongs to Northwind and Globex. He opens globex.yourcrm.com, and your app starts the login with Globex’s organization ID in the authorization URL (organization_id=org_93418204671239865).

  1. John signs in with a magic link.
  2. Scalekit calls your endpoint. auth_request.organization_id is Globex, and data.user.memberships has an active Globex membership.
  3. Your endpoint returns select_organization with Globex.
  4. Scalekit skips the switcher, runs Pre-session creation, and returns John to the CRM logged in to Globex.

If John had no active Globex membership, the endpoint below returns plain ALLOW and John picks from the switcher.

Express.js
app.post('/auth/interceptors/post-user-authentication', (req, res) => {
const { interceptor_context, data } = req.body;
const workspaceOrgId = interceptor_context.auth_request?.organization_id;
const memberships = data.user?.memberships ?? [];
const isActiveMember = memberships.some(
(m) => m.organization_id === workspaceOrgId && m.status === 'ACTIVE'
);
if (!workspaceOrgId || !isActiveMember) {
return res.json({ decision: 'ALLOW' }); // The user picks an organization
}
return res.json({
decision: 'ALLOW',
response: { select_organization: { organization_id: workspaceOrgId } }
});
});

The login stops with an error in these cases:

CauseError
decision is DENYuser_auth_not_allowed, with your error.message or the generic message
Both organization fields in one response, or one with neither IDuser_auth_failed
external_organization_id matches no organization in the environmentinvalid_organization_id
select_organization names an organization the user doesn’t belong tomembership_not_found
select_organization names an inactive or deleted membershipaccess_denied
create_organization_membership targets an organization at its user limitaccess_denied
Authentication flow

Pre-signup runs when a user signs up and would create a new organization. It does not run when the user joins an existing organization, for example through Post-user authentication or an invitation.

Use it to block sign-ups, or to add the user to an existing organization instead. A first-time user arrives with data empty ({}). A user who already belongs to another organization arrives with user_id and data.user, as in this request:

Request body
{
"display_name": "Add new users to their company's workspace",
"trigger_point": "PRE_SIGNUP",
"interceptor_context": {
"environment_id": "env_92561807201272162",
"user_id": "usr_93418238346728951", // Only when the user already exists
"user_email": "jane.smith@initech.com",
9 collapsed lines
"auth_request": {
"client_id": "skc_92561807201272100",
"connection_id": "conn_81665025441299343",
"login_hint": "jane.smith@initech.com",
"state": "xsrPHl7k7ARgdhC6"
},
"connection_details": [
{ "id": "conn_92561808744978132", "type": "OAUTH", "provider": "GOOGLE" }
],
"device_type": "Desktop",
"ip_address": "203.0.113.24",
"region": "US",
"city": "San Francisco",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36...",
"triggered_at": "2026-10-13T09:48:02.875Z"
},
"data": {
"user": { // Only when the user already exists
"id": "usr_93418238346728951",
"email": "jane.smith@initech.com",
"memberships": [
{ "organization_id": "org_93418204671239864", "organization_name": "Northwind", "status": "ACTIVE", "roles": ["member"] }
],
6 collapsed lines
"name": "Jane Smith",
"given_name": "Jane",
"family_name": "Smith",
"email_verified": true,
"created_at": "2025-10-06T11:06:49.120Z",
"updated_at": "2025-10-06T13:33:06.479Z"
}
}
}

Return ALLOW with no response to let the user create a new organization. To block the sign-up, return DENY. The user sees signup_not_allowed with your error.message. To add the user to an existing organization, return create_organization_membership with organization_id or external_organization_id (organization_id wins if you send both) and optional roles.

Each response this trigger point accepts:

Response: create a new organization
{
"decision": "ALLOW"
}

Jane joins Initech’s sales team and signs up with her @initech.com address. Initech already has a workspace, so your endpoint maps the email domain to Initech’s external ID and adds her there. An address from any other domain gets plain ALLOW, and the user creates a new workspace as usual.

Express.js
// Email domain → your external ID for the organization
const domainToOrg = {
'northwind.com': 'northwind',
'globex.com': 'globex',
'initech.com': 'initech'
};
app.post('/auth/interceptors/pre-signup', (req, res) => {
const email = req.body.interceptor_context.user_email;
const externalOrgId = domainToOrg[email.split('@')[1]?.toLowerCase()];
if (!externalOrgId) {
return res.json({ decision: 'ALLOW' }); // Creates a new organization
}
return res.json({
decision: 'ALLOW',
response: {
create_organization_membership: { external_organization_id: externalOrgId, roles: ['member'] }
}
});
});

The request also carries ip_address and region, so the same endpoint can deny sign-ups from addresses or countries you block.

Authentication flow

Pre-session creation runs at the end of every login, before Scalekit issues the session tokens, however the user signed in. That includes an invitee’s first login from the invitation email and a switch to another organization. By now Scalekit knows the organization, so the request carries organization_id, and data.user.current_membership when Scalekit finds the user’s membership in it. Check that the field is present before you read its roles.

Use it to deny a login, or to add claims to the access token. Scalekit puts the claims you return under custom_claims, saves them on the session, and keeps them on every refreshed access token. A change in your systems therefore shows up at the user’s next login. To compute claims each time a token is issued, refresh included, use session token claims instead.

Request body
{
"display_name": "Add plan claims",
"trigger_point": "PRE_SESSION_CREATION",
"interceptor_context": {
"environment_id": "env_92561807204567213",
"user_id": "usr_93418238346728951",
"user_email": "john.doe@example.com",
"organization_id": "org_93418204671239865", // The organization the user is logging in to
12 collapsed lines
"auth_request": {
"client_id": "skc_92561807201272100",
"connection_id": "conn_92561808744978132",
"organization_id": "org_93418204671239865",
"state": "xsrPHl7k7ARgdhC6"
},
"connection_details": [
{ "id": "conn_92561808744978132", "type": "OAUTH", "provider": "GOOGLE" }
],
"device_type": "Desktop",
"ip_address": "203.0.113.24",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36...",
"triggered_at": "2026-10-13T15:22:42.381Z"
},
"data": {
"user": {
"id": "usr_93418238346728951",
"current_membership": { // When Scalekit finds the membership for organization_id
"organization_id": "org_93418204671239865", "organization_name": "Globex", "status": "ACTIVE", "roles": ["member"] },
"memberships": [
{ "organization_id": "org_93418204671239864", "organization_name": "Northwind", "status": "ACTIVE", "roles": ["admin"] },
{ "organization_id": "org_93418204671239865", "organization_name": "Globex", "status": "ACTIVE", "roles": ["member"] }
],
7 collapsed lines
"email": "john.doe@example.com",
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"email_verified": true,
"created_at": "2025-10-06T11:06:49.120Z",
"updated_at": "2025-10-06T13:33:06.479Z"
}
}
}

Return ALLOW with no response to issue the tokens unchanged. To deny the login, return DENY. The user sees user_auth_not_allowed with your error.message. To add claims, return them in response.claims.

Each response this trigger point accepts:

Response: issue the tokens unchanged
{
"decision": "ALLOW"
}

An endpoint that adds the user’s plan from your own records:

Express.js
app.post('/auth/interceptors/pre-session-creation', async (req, res) => {
const { user_id, organization_id } = req.body.interceptor_context;
// Look up what your app knows about this user in this organization
const account = await getAccount(user_id, organization_id);
return res.json({
decision: 'ALLOW',
response: {
claims: { plan: account.plan, features: account.features }
}
});
});

The access token then carries them:

Decoded access token
{
"sub": "usr_93418238346728951",
"oid": "org_93418204671239865",
"custom_claims": {
"plan": "pro",
"features": ["forecasting", "api_access"]
},
"roles": ["member"],
"sid": "ses_107201917586768386",
"iss": "https://yourapp.scalekit.dev",
"exp": 1767964824
}

Keep claims small, since the token travels with each request to your API. Custom claims never appear in the scope claim; for scopes your resource server checks, use custom scopes and permissions.

Outside the login flow, Pre-user invitation runs before a user is added to an organization from the dashboard or the API (Add users to organizations), whether the user is new or already belongs to another organization. Use it to enforce who may be invited, such as allowed email domains or seat limits you track yourself.

Request body
{
"display_name": "Allow company addresses only",
"trigger_point": "PRE_USER_INVITATION",
"interceptor_context": {
"environment_id": "env_92561807201272162",
"user_id": "usr_93418238346728951", // Only when the invitee already exists
"user_email": "sarah.johnson@contractor.com", // The invitee
"organization_id": "org_93731871904672153",
"ip_address": "203.0.113.24", // The person sending the invitation
"device_type": "Desktop",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36...",
"triggered_at": "2026-10-13T12:50:41.803Z"
},
"data": {
"organization": {
"id": "org_93731871904672153",
"name": "Initech",
"external_id": "initech" // Only when the organization has one
}
}
}

The response takes only decision and error.message. A DENY fails the invitation request with HTTP 400 and code USER_INVITE_NOT_ALLOWED, carrying your message. Under Fail closed, an endpoint failure returns HTTP 400 with USER_INVITE_VALIDATION_FAILED and the generic message.

Each response this trigger point accepts:

Response: send the invitation
{
"decision": "ALLOW"
}

Pre-M2M token creation runs before Scalekit issues a machine-to-machine access token to an M2M client. data.m2m_token_claims.claims holds the claims Scalekit puts in the token.

Request body
{
"display_name": "Add sync service claims",
"trigger_point": "PRE_M2M_TOKEN_CREATION",
"interceptor_context": {
"environment_id": "env_17002334043308132",
"client_id": "m2morg_93710427703245914",
"user_agent": "crm-sync-service/2.1.0",
"device_type": "Unknown",
"triggered_at": "2026-10-13T21:22:20.173Z"
},
"data": {
"m2m_token_claims": {
"client_id": "m2morg_93710427703245914",
"claims": {
"oid": "org_89669394174574792",
"scope": "contacts:read deals:write",
"scopes": ["contacts:read", "deals:write"],
"custom_claims": { "service_name": "warehouse-sync" } // From the client's configuration
}
}
}
}

DENY fails the token request. With ALLOW, Scalekit merges response.claims into the token’s custom_claims, and a key you return replaces the same key from the client’s configuration. You cannot change scope, aud or any other top-level claim this way: a scope you return lands at custom_claims.scope.

Each response this trigger point accepts:

Response: issue the token unchanged
{
"decision": "ALLOW"
}