SSO-Only User Deprovisioning

Give customers the power to instantly revoke application access while keeping Wristband entirely data-free.

📘

SSO-Only Integrations Only

This guide covers user deprovisioning for SSO-only integrations (where your application manages and stores its own users).

In an SSO-only integration, Wristband solely brokers the login handshake, leaving your application to own the resulting session and access tokens. Consequently, revoking access in an organization's IdP blocks future logins but does not terminate active sessions. Affected users retain access until their session expires or you explicitly deprovision them.

Similar to our core User Deprovisioning workflow, you can build a custom API layer—referred to in this guide as a wrapper API—that allows customers to trigger deprovisioning directly without your manual intervention.

The sections below detail how to design and implement this wrapper API.

⚠️

Confirm Requirements Before Building

Only consider building a wrapper API if your customer requires automated, self-service deprovisioning and that requirement isn't negotiable.


How the Wrapper API Works

When your customer calls your exposed deprovision endpoint, their system won't have a Wristband userId since it never interacts with Wristband directly. Instead, the request will use whatever identifier exists on their side, such as an externalId or email.

Your endpoint should:

  1. Authenticate the incoming customer request.
  2. Validate the target user's externalId or email from the request.
  3. Deactivate or delete that user directly in your own system.

Wristband manages this first step: authenticating the customer.

⚠️

Already Have Your Own API Keys?

If your application already has an API key system, you can reuse it instead, and skip 1) Create a Tenant-Level M2M Client for Your Customer and 2) Create a Token Endpoint Wrapper API entirely. Be aware that Wristband would not be involved, and authenticating incoming requests would be entirely your responsibility.

The rest of this page assumes you're using a Wristband tenant-level M2M client to authenticate your customer instead.

Here's how that looks end to end:

%%{init: {'themeVariables': {'fontSize': '16px'}}}%%
sequenceDiagram
    participant Customer as Customer's backend
    participant Wrapper as Your wrapper API
    participant Token as Wristband Token Endpoint
 
    Customer->>Wrapper: Token request (client creds)
    Wrapper->>Token: Proxy request
    Token-->>Wrapper: Access token
    Wrapper-->>Customer: Access token
 
    Customer->>Wrapper: Deprovision request (Bearer token)
    Wrapper->>Wrapper: Validate token
    Wrapper->>Wrapper: Deactivate or delete user
    Wrapper-->>Customer: 200 or 204

1) Create a Tenant-Level M2M Client for Your Customer

📘

Note:

Skip this section if you are using your own API key system.

To authenticate customer requests to your wrapper API, provision a Wristband tenant-level M2M client for them. Your customer will use this clientId and clientSecret to fetch access tokens from Wristband and include them in each request header. Your wrapper API must validate these tokens before processing the payload.

Follow the instructions in Tenant-Level Clients to create one. Because this client only verifies the customer's identity to your wrapper API, it does not require any Wristband resource permissions.

Once generated, share the following credentials with your customer through a secure channel:

FieldDescription
Client IDThe clientId of the tenant-level M2M client you created for this customer.
Client SecretThe clientSecret of the tenant-level M2M client you created for this customer.

2) Create a Token Endpoint Wrapper API

📘

Note:

Skip this section if you are using your own API key system.

Since your customer's system cannot call Wristband directly, your wrapper API must act as a bridge for token generation.

  1. Expose a custom token exchange endpoint for your customer.
  2. Accept their tenant-level M2M credentials.
  3. Proxy the request directly to Wristband's Token Endpoint.
  4. Return the generated access token back to your customer.

Suggested Spec

The following specification is a suggested implementation. The exact design is completely up to you.

Request

POST /api/v1/auth/token HTTP/1.1
Host: yourapp.com
Content-Type: application/json
 
{
  "clientId": "abc123",
  "clientSecret": "def456"
}

Headers

NameRequiredDescription
Content-TypeYesMust be application/json.

Body

FieldTypeRequiredDescription
clientIdstringYesThe clientId of the tenant-level M2M client you issued to this customer.
clientSecretstringYesThe clientSecret of the tenant-level M2M client you issued to this customer.

Response

HTTP/1.1 200 OK
Content-Type: application/json
 
{
  "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expiresIn": 1800
}

HTTP Response Codes

StatusDescription
200 OKThe credentials were valid, and an access token is included in the response body.
400 Bad RequestThe request body was missing clientId or clientSecret.
401 UnauthorizedThe clientId and clientSecret combination was invalid.

Code Example

For example, a TypeScript function that proxies the request to Wristband's Token Endpoint:

async function handleTokenRequest(clientId: string, clientSecret: string) {
  const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
 
  const response = await fetch(`https://${process.env.APPLICATION_VANITY_DOMAIN}/api/v1/oauth2/token`, {
    method: 'POST',
    headers: {
      Authorization: `Basic ${credentials}`,
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: 'grant_type=client_credentials',
  });
 
  if (!response.ok) {
    throw new Error(`Failed to get token: ${response.status}`);
  }
 
  const { access_token, expires_in } = await response.json();
  return { accessToken: access_token, expiresIn: expires_in };
}

3) Create a Deprovision Endpoint Wrapper API

Because your users live entirely in your own system, this endpoint doesn't need to resolve a Wristband userId. It only requires enough information to find the user in your database, such as an externalId (matching their IdP identifier) or the user's email.

Suggested Spec

The following specification is a suggested implementation. The exact design is completely up to you.

Request

POST /api/v1/users/deprovision HTTP/1.1
Host: yourapp.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
 
{
  "email": "[email protected]"
}

Headers

NameRequiredDescription
AuthorizationYesA Bearer token authenticating your customer's call. See Validate Your Customer's Requests.
Content-TypeYesMust be application/json.

Body

FieldTypeRequiredDescription
externalIdstringConditionallyWhatever identifier you use to look up the user in your own system. Required if email isn't provided.
emailstringConditionallyThe user's email address. Required if externalId isn't provided.
📘

Note:

The request payload must include exactly one identifier: either externalId or email. If your wrapper API only supports one of these options, remove the other from your specification entirely.

Response

HTTP/1.1 204 No Content

HTTP Response Codes

StatusDescription
204 No ContentThe user was successfully deprovisioned
400 Bad RequestThe request body was missing required fields, or included both externalId and email.
401 UnauthorizedThe Bearer token was missing, invalid, or failed tenant validation.
404 Not FoundNo user was found matching the provided identifier.

Validate Your Customer's Requests

In your deprovision endpoint, use a Wristband JWT SDK to validate the incoming access token. Because the deprovision endpoint payload does not contain an explicit customer or tenant identifier, use the token's tnt_id (tenant ID) claim to safely identify the caller.

For reference, here is what a decoded access token looks like for a tenant-level M2M client:

{
  "sub": "sqi7mel4prbixj62rlruslyi64",
  "tnt_id": "ffioj5rc3bd6rnwr7jxhvycfoa",
  "van_dom": "invotastic-demo-wristband.us.wristband.dev",
  "iss": "https://invotastic-demo-wristband.us.wristband.dev",
  "sub_kind": "tenant_client",
  "exp": 1785197092,
  "app_id": "vl6jdobxy5gafkr3dvztbvzrue",
  "iat": 1785110692,
  "jti": "5hbgvgrlejglplvwokwttvazha",
  "client_id": "sqi7mel4prbixj62rlruslyi64"
}

After successfully verifying the token's signature, you can trust the tnt_id claim and use it to look up the corresponding customer record in your database.

Code Example

For example, you can use the TypeScript JWT SDK to validate the token and extract the tenantId:

import { createWristbandJwtValidator } from '@wristband/typescript-jwt';
 
// Create once and reuse across requests.
const wristbandJwtValidator = createWristbandJwtValidator({
  wristbandApplicationVanityDomain: process.env.APPLICATION_VANITY_DOMAIN!,
});
 
async function validateCustomerRequest(authorizationHeader: string): Promise<string> {
  // Pull the raw Bearer token out of the Authorization header.
  const token = wristbandJwtValidator.extractBearerToken(authorizationHeader);
 
  // Verify the token's signature, issuer, and expiration.
  const result = await wristbandJwtValidator.validate(token);
 
  if (!result.isValid) {
    throw new Error(`Invalid token: ${result.errorMessage}`);
  }
 
  // A valid signature means this tnt_id can be trusted as the calling customer's tenant.
  const tenantId = result.payload?.tnt_id;
  if (!tenantId) {
    throw new Error('Token is missing a tenant ID');
  }
 
  return tenantId;
}

Mapping by Tenant Name

The token's tnt_id claim identifies the customer by their Wristband tenant ID. If your system tracks customers using the Wristband tenant name instead of the ID, you must explicitly include the tenant's name in the access token.Because the tenant name is not included by default, you can add it as a custom claim:

  1. Navigate to Security → Token Settings in the Application View.
  2. Create a new custom claim using the tenant.name path expression.
  3. Assign it a claim name of your choosing (such as tnt_name).
Token Settings: Add a custom claim using the tenant.name path expression.

Token Settings: Add a custom claim using the tenant.name path expression.

Once configured, it appears nested under custom_claims in the token payload. For example:

{
  "sub": "sqi7mel4prbixj62rlruslyi64",
  "tnt_id": "ffioj5rc3bd6rnwr7jxhvycfoa",
  "van_dom": "invotastic-demo-wristband.us.wristband.dev",
  "iss": "https://invotastic-demo-wristband.us.wristband.dev",
  "sub_kind": "tenant_client",
  "exp": 1785197092,
  "app_id": "vl6jdobxy5gafkr3dvztbvzrue",
  "iat": 1785110692,
  "jti": "5hbgvgrlejglplvwokwttvazha",
  "client_id": "sqi7mel4prbixj62rlruslyi64",
  "custom_claims": {
    "tnt_name": "acme"
  }
}

Deprovision the User

Once the request is validated, locate the user in your database using the provided externalId or email, and either deactivate or delete their record. This step relies entirely on your application's internal business logic—Wristband is not involved in this process.

Your wrapper API does not need to support both actions. You can choose a single strategy (deactivation or deletion) and design your endpoint around it. If you want to support both, you must provide a way for the client to specify the action, such as:

  • Exposing separate endpoints for each action.
  • Utilizing different HTTP methods (e.g., PATCH vs. DELETE).
  • Including a flag or field in the request body

Putting It All Together

Below is a complete, framework-agnostic TypeScript example combining both the token proxy and deprovision endpoints. Written as plain functions, you can easily drop this logic into Express, Next.js, Fastify, or any HTTP framework you use.

📘

Additional Languages

For other languages and frameworks, refer to Authentication SDKs for the full list of available JWT validation SDKs.

import { createWristbandJwtValidator } from '@wristband/typescript-jwt';
 
const VANITY_DOMAIN = process.env.APPLICATION_VANITY_DOMAIN!;
 
// JWT validator used to validate the customer's incoming access tokens.
const wristbandJwtValidator = createWristbandJwtValidator({
  wristbandApplicationVanityDomain: VANITY_DOMAIN,
});
 
// Validates a customer's incoming request and returns the tenant it belongs to.
async function validateCustomerRequest(authorizationHeader: string): Promise<string> {
  const token = wristbandJwtValidator.extractBearerToken(authorizationHeader);
  const result = await wristbandJwtValidator.validate(token);
 
  if (!result.isValid) {
    throw new Error(`Invalid token: ${result.errorMessage}`);
  }
 
  const tenantId = result.payload?.tnt_id;
  if (!tenantId) {
    throw new Error('Token is missing a tenant ID');
  }
 
  return tenantId;
}
 
// Deprovisions a user identified by either externalId or email.
export async function deprovisionUser(
  authorizationHeader: string,
  externalId: string | undefined,
  email: string | undefined
) {
  if ((!externalId && !email) || (externalId && email)) {
    throw new Error('Provide exactly one of externalId or email');
  }
 
  const tenantId = await validateCustomerRequest(authorizationHeader);
 
  // NOTE: This is where you look up and deactivate or delete the user in
  // your own system. Scope the lookup to `tenantId` if your data model
  // supports it.
  await yourOwnUserStore.deactivateOrDelete({ tenantId, externalId, email });
}

(Optional) Client Secret Rotation

Because your customer holds a long-lived client secret for their tenant-level M2M client, you can allow them to rotate it on their own schedule without manual intervention.

The secret rotation workflow functions exactly the same way as it does for Wristband-stored users. Please refer to Client Secret Rotation in our primary User Deprovisioning guide for the complete specification and code example. While the underlying user deprovisioning methods differ, the rotation mechanics are identical.


Did this page help you?