GeistHaus
log in · sign up

Okta Developer

Part of Okta Developer

Secure, scalable, and highly available authentication and user management for any app.

stories primary
How to Build Low-Code API Integrations for Enterprise Apps Using Okta

API Integration Actions are now available in Okta Integration Network (OIN) for Integrator Free Trial Orgs to build Provisioning, Entitlements, and Universal Logout applications.

What are API Integration Actions?

API Integration Actions are a feature that uses Workflows, Okta’s low-code builder, to enable independent software vendors (ISVs) to build Okta Integrations (Provisioning, Entitlements, Universal Logout) that are seamlessly invoked by Okta services — for example, retrieving and updating entitlements or triggering risk-based logout flows.

You can just skip the complexity of building and maintaining a System for Cross-domain Identity Management (SCIM) server. API Integration Actions allow you to use your existing APIs as-is by mapping them directly to Okta action contracts. By using our low-code builder, you no longer need in-depth knowledge of protocols, making it faster and easier to build, test, and deliver enterprise-grade Secure Identity Integrations. This leads to a fast time-to-value for customers leveraging ISV data for connector-heavy Okta Identity Governance (OIG) use cases.

Benefits of low-code API integration for ISVs

For the ISV application developer:

  • Built on Workflows: use the low-code builder instead of writing and maintaining complex code
  • Translates your API calls into formats consumable by Okta: bring your APIs as they are, without having to make any changes
  • No need for in-depth knowledge of protocols: Workflows makes mapping your API to Okta’s format simple
  • No need to invest in costly infrastructure: don’t worry about managing a SCIM server
  • It’s not just secure — it’s fast and easy!
How to build low-code API integrations with Okta Workflows

If you don’t already have an account, sign up for an Okta Integrator Free Plan first. Once created, log in and follow these steps.

Step 1: Create your OIN integration
  • Click Applications > Your OIN Integrations
  • Click Build new OIN integration
  • Choose the single sign-on (SSO) type
  • If you are building an integration that uses Universal Logout, choose that option. If you are building an integration using provisioning and entitlements, choose those options
  • Select View integration details

Add integration capabilities screen showing Session Lifecycle Management and Identity Lifecycle Management options

  • Add the integration details
  • If you are a customer creating an integration for your orgs during the EA period, put “Customer-created integration - not for the public catalog” in the description field. Then provide a list of your org tenant IDs and subdomains. After submission, you will need to email your account manager to ensure this integration is deployed to your orgs.

OIN catalog properties form showing display name, description, and logo upload fields

OIN catalog properties form continued showing support contact information and use case options

Step 2: Configure authentication and API Integration Actions
  • Tenant settings refer to subdomains or additional information needed for the SSO components
  • Authentication settings include all of the allowed integration types. Choose the one used by the API and provide the information
  • Click Save and start building

Tenant settings and authentication settings screens showing label, name fields, and OAuth 2 configuration with authorize and token endpoints

  • This will send you to Integration Builder within the Okta Workflows product, where you build out the flows that connect to the API
  • Validate that the information is correct — it should match what was provided in OIN Wizard

Integration Builder project screen showing General, Authentication, Test connection, and API Spec tabs

  • Click on the Authentication tab and add the authentication information. Make sure it matches what is in the OIN Wizard
  • Fill out the Authentication Mapping section to map the OIN Wizard auth parameters to the Workflows auth parameters

Authentication mapping screen showing connection parameters mapped to OIN app integration variables

  • Click on New Component and choose Add Action
  • Choose the API Integration Action component from the list, and click save

Add new action dialog showing API integration action component options, including Provisioning action contracts

Step 3: Build your low-code workflow flows
  • Click on New Flow
  • Create the workflow and repeat as necessary
  • Once your flows are created, you can create test flows in the test folder to validate that the API calls are being made correctly

Provisioning action contracts screen showing App Event flows for List users, Get group by id, List groups, and more

  • After testing, click on Validate and Submit
  • Click on Validate flows and fix any errors that may exist

Validate and submit flows screen showing flow validation status and Continue submission in OIN button

  • Click on Continue submission in OIN
  • Back in the OIN Wizard, choose the correct flows for each of the API Integration Actions that have been created
  • Click on Get started with testing

Provisioning API Integration Actions screen showing User query, User Schema Discovery, and User Operations flow mapping

How to test your API integration before publishing to the OIN

Before submitting your integration for review and publication, you must test it in your Okta org. Your integration will only be available on your Okta org. Okta admins will see the same authorization experience.

  • Provide the testing information needed for Okta to review the submission
  • Once finished, click on Test your integration

Test your integration screen showing test account fields, account URL, username, password, and testing instructions

Create a test instance
  • Fill out the information, including the test account and any SSO testing features on the Test your integration section of the OIN Wizard
  • Click Test your integration
  • Follow the instructions in the Test integration section to generate a test instance and complete all of the testing
  • Validate your flows by clicking the button — take action on any failures that occur

Test integration screen showing app instances for testing with SAML SSO instance detected and Provisioning and Entitlement instances pending

Update a test instance

When you make an update to your submission in the OIN Manager (for example, modifying the scopes or name of the integration), the update will not automatically be reflected in your test instance for security reasons.

To update a test instance, repeat the procedure above for creating a test instance.

Once finished, click the last checkbox to enable submitting your integration for review. This process is similar to the existing OIN Catalog process.

Get started with low-code API integration using Okta

If you’re ready to build an integration between your APIs and Okta’s, start by exploring how to build and publish an application using API Integration Actions to the OIN by reading our product documentation Build and publish API Integration Actions.

Remember to follow us on Twitter and subscribe to our YouTube channel for more exciting content. We also want to hear from you about the topics you’d like to see and any questions you may have. Leave us a comment below!

https://developer.okta.com/blog/2026/05/12/low-code-api-integration
Develop a XAA-Enabled Resource Application and Test with Okta

From an enterprise resource app owner’s perspective, Cross App Access (XAA) is a game-changer because it allows their resources to be “AI-ready” without compromising on security. In the XAA model, resource apps rely on the enterprise’s Identity Provider (IdP) to manage access. Instead of building out interactive OAuth flows, they defer to the IdP to check enterprise policies and user groups, assign AI agent permissions, and log and audit AI agent requests as they occur. In return, the app’s OAuth server needs only to perform a few checks:

  • When the app’s OAuth server receives a POST request to its token endpoint from an AI agent, the app fetches the IdP’s public keys (via the JWKS endpoint) to ensure the ID-JAG token attached to the request was actually minted by the trusted company IdP.
  • It confirms the token was intended for this app specifically. If the aud claim doesn’t match the app’s own identifier, it rejects the request.
  • Finally, it checks the end user ID in the token’s sub claim to know whose data to look up in your database. It must map to the same IdP identity. It will reject the request if the user isn’t recognized.

You can read in depth about XAA to better understand how this works and examine the token exchange flow.

Integrate Your Enterprise AI Tools with Cross-App Access

Manage user and non-human identities, including AI in the enterprise with Cross App Access

avatar-avatar-semona-igama.jpeg Semona Igama

Or watch the video about Cross App Access:

In this tutorial, we’ll demonstrate how to test that an XAA-enabled resource app you have created (TaskFlow) is correctly using Okta as an enterprise Identity Provider (IdP) to sign users in, and we’ll demonstrate how a sample AI app (Agent0) uses XAA to get access to TaskFlow. To do this, you’ll:

  • Enable Cross App Access in your Okta org
  • Register and configure the resource app (TaskFlow) in your org
  • Register the requesting app (Agent0) in your org as a known XAA app and connect it to TaskFlow.
  • Test that the XAA flow is working correctly when Agent0 requests access to TaskFlow.

Note that the apps (TaskFlow or Agent0) do not use Okta as their authorization server.

Enable Cross App Access in your Okta org

To register your resource app with Okta, and set up secure agent-to-app connections, you’ll need an Okta Developer org enabled with XAA:

  • If you don’t already have an account, sign up for a new one here: Okta Integrator Free Plan
  • Once created, sign in to your new Integrator Free Plan org
  • In the Okta Admin Console, select Settings > Features
  • Navigate to Early access
  • Find Cross App Access and select Turn on (enable the toggle)
  • Refresh the Admin Console

Note: Cross App Access is currently a self-service Early Access (EA) feature. You must enable it through the Admin Console before the apps appear in the catalog. If you don’t see the option right away, refresh and confirm you have the necessary admin permissions. Learn more in the Okta documentation on managing EA and beta features.

Register your requesting app (Agent0)

To test whether your resource app is working correctly, Okta provides a placeholder entry in the Okta Integration Network catalog. It is called Agent0 - Cross App Access (XAA) Sample Requesting App. Add this to your org’s integrations.

  • Still in Admin Console, go to Applications > Applications
  • Select Browse App Catalog
  • Search for “Agent0 - Cross App Access (XAA) Sample Requesting App”, and select it
  • Select Add Integration

Now to configure it correctly. First, assign user access to Agent0.

  • Change the Application label if required, and select Done,
  • Select the Assignments tab
    • To assign it to a single user, select Assign > Assign to People and choose your user
    • To assign it to a user group, select Assign > Assign to Groups and choose your user group
  • Click Done

Finally, configure Agent0 with the redirect URI you will use to test Agent0

  • Select the Sign On tab
  • Select Edit, and locate the Advanced Sign-on Settings section.
  • Set the Redirect URI to the URL that your app will use. For example, http://localhost:8080/redirect
  • Click Save.
  • Locate and copy the Client ID and Client secret in the Sign-On methods section. Your app must use these when signing users in through Okta.

Note: Only the org authorization server can be used to exchange ID-JAG tokens. Ensure you are using the org authorization server and not an Okta “custom authorization server”.

Get a (XAA) Client ID for Agent0 from the Resource app’s Auth Server

To allow the exchange of an ID-JAG token between Agent0 and your resource app, Agent0 must be registered as an OAuth client in your resource app’s OAuth server.

  • Register your requesting app (Agent0) as an OAuth client in your resource app’s OAuth server.
  • Make a note of the Client ID for your requesting app (Agent0). You’ll need this as you set up your resource app.

Note: The process for registering a client ID from your resource app’s OAuth server will vary depending on the product.

Set up your resource app (TaskFlow)

To set up your resource app in your org, you can use the placeholder integration in the OIN catalog called Todo0 - Cross App Access (XAA) Sample Resource App and configure it as your resource app.

  • Still in Admin Console, navigate to Applications > Applications
  • Select Browse App Catalog
  • Search for Todo0 - Cross App Access (XAA) Sample Resource App, and select it
  • Select Add Integration

Now give it a helpful name and assign user access to TaskFlow.

  • Set the Application label to TaskFlow, and click Done.
  • Select the Assignments tab
    • To assign it to a single user, select Assign > Assign to People and choose your user
    • To assign it to a user group, select Assign > Assign to Groups and choose your user group
  • Click Done
Update the audience value of your Resource app’s auth server

By default, Okta will issue an ID-JAG token for Agent0 with the audience (aud) value set to that of the sample resource app (Todo0): http://localhost:5001/. You must change this so the ID-JAG token includes an audience value that identifies your actual resource app’s authorization server.

To do this, contact the Okta XAA team to replace your app’s audience value in Okta by sending an email to xaa@okta.com. Provide the following information to the Okta XAA team:

Okta Integrator Org URL: ‘https://{yourOktaDomain}’
Audience: ‘http://yourresourceapps.authserver.org’ Client ID from your own OAuth server: [Agent0’s XAA client ID you created earlier]

Please note that the Client ID you provide must be the client ID from your own OAuth server that was created earlier.

Establish Connections between Agent0 and your resource app

Now that you have set up both requesting and resource apps, you need to establish that Agent0 can be trusted to make requests to your resource app.

  • Still in Admin Console, navigate to Applications > Applications > Agent0
  • Go to the Manage Connections tab
  • Under Apps providing consent, select Add resource apps, select TaskFlow, then Save
  • Confirm that your resource app appears under Apps providing consent

Now Agent0 and TaskFlow are connected.

Validate that your Resource App and Auth Server work as intended

Once the Okta XAA team confirms that your app’s audience value has been updated in Okta, Agent0 can make a Token Exchange request to Okta and will receive an ID-JAG with the correct audience.

To test the end-to-end XAA flow with Agent0 to your authorization server, create a testing client that completes the following steps:

  1. Agent0 signs the user in with OIDC.
  2. Agent0 exchanges the ID token for an ID-JAG at Okta
  3. Agent0 makes a token request with the ID-JAG at your authorization server

If you need support with taking the steps above, contact xaa@okta.com.

With testing complete, consider publicizing your resource app on the Okta Integration Network (OIN) catalog. Adding it to the catalog makes it easy for Okta’s roughly 18000 enterprise customers to learn about and add it to the suite of tools on their Okta dashboards.

Learn more about Cross App Access, OAuth 2.0, and securing your applications

If this walkthrough helped you understand more about how Cross App Access works in practice, consider learning more about

📘 xaa.dev - a free, open sandbox that lets you explore Cross App Access end-to-end. No local setup. No infrastructure to provision. Just a working environment where you can see the protocol in action.
📘 Okta’s Cross App Access Documentation – official guides and admin docs to configure and manage Cross App Access in production
🎙️ Okta Developer Podcast on MCP and Cross App Access – hear the backstory, use cases, and why this matters for developers
📄 OAuth Identity Assertion Authorization Grant (IETF Draft) – the emerging standard that powers this flow

https://developer.okta.com/blog/2026/02/17/xaa-resource-app
Make Secure App-to-App Connections Using Cross App Access

Imagine you built a note-taking app. It’s so successful that LargeCorp, an aptly named large enterprise corporation, signed on as a customer. To make it a power tool for your enterprise customers, you need to allow your app to integrate with other productivity tools, such as turning a note into a task in a to-do app.

While common integration patterns work well for individual users, these patterns create security and compliance hurdles for large organizations.

Limitations of API keys and OAuth in enterprise app-to-app connectivity

Connecting independent apps usually involves one of two common strategies. Both have significant drawbacks when used in a corporate environment:

  • API keys and service accounts These lack user context. They often lead to over-privileged access and create challenging rotation requirements.
  • Standard OAuth 2.0 A much better, industry-standard best practice over API keys and service accounts, but this relies on individual user consent. IT admins cannot see or control which apps employees connect to, creating shadow IT risks and compliance and security concerns.
Cross App Access (XAA) extends OAuth flows to manage application access

Cross App Access is an OAuth extension based on the Identity Assertion Authorization Grant. It addresses these challenges by using the Enterprise Identity Provider (IdP) as a central broker and was proposed by a collaborative group of organizations and interested individuals.

With XAA, the Identity Provider (IdP) facilitates a secure token exchange. This provides three main benefits.

  • IT Governance - Admins centrally manage and approve app-to-app connections
  • Reduced friction - Users avoid repeated and confusing consent prompts
  • Granular security - Access is limited to specific users and specific tasks.

You can read in depth about XAA in Integrate Your Enterprise AI Tools with Cross App Access to better understand how this works and to look at the token exchange flow

Integrate Your Enterprise AI Tools with Cross-App Access

Manage user and non-human identities, including AI in the enterprise with Cross App Access

avatar-avatar-semona-igama.jpeg Semona Igama

In this tutorial, we’ll add XAA to connect a note-taking app to a to-do app using xaa.dev as our testing ground.

Table of Contents

Make app-to-app requests using Cross App Access

We’re using NestJS in this project. The tech stack relies on TypeScript, and we’ll use an OpenID Connect (OIDC) client library to communicate with the IdP and the to-do app’s OAuth Authorization server. Using a well-maintained OIDC client library is a best practice when creating apps that use OAuth flows, as it helps ensure you don’t make subtle errors in OAuth handshakes that compromise security.

For this workshop, you need the following required tooling:

Required tools

  • Node.js LTS version (v22 or higher at the time of this post)
  • Command-line terminal application
  • A code editor/Integrated development environment (IDE), such as Visual Studio Code (VS Code)
  • Git

Note

This code project is best for developers with web development and TypeScript experience and familiarity with OAuth and OpenID Connect (OIDC) flows at a high level.

If you want to skip directly to the working project, you can find it in the GitHub repo.

Bring your own requestor app to the xaa.dev testing site

The xaa.dev testing site supports testing local client apps. It’s IdP-agnostic, meaning it’s focused on the spec and education, not on a specific company’s product line. In this scenario, we can verify whether our client app, the note-taking app, handles the token exchange with an IdP and the resource app’s authorization server. The best part about this testing site is that it’s self-contained and works out of the box. So you don’t need to create an account with an IdP, nor do you have a resource app with a conformant OAuth authorization server! We just have to bring our client code for testing! Yay for simplicity!

You can read more about the site here:

Introducing xaa.dev: A Playground for Cross App Access

Explore Cross App Access end-to-end with xaa.dev, a free, open playground that lets you test the XAA protocol without any local setup or infrastructure.

avatar-avatar-sohail-pathan.jpeg Sohail Pathan

Let’s register our note-taking app now.

In your browser, navigate to xaa.dev. The main site provides information about the players in this flow, and you can test the XAA flow step by step there. Please take a moment to step through the flow to get a better sense of the code we’ll build.

When you’re ready, navigate to Developer > Register Client. Add a totally made-up email for more fun when registering.

Select + Register New Client and fill out the required information:

  • Application Name - I used “Notes App”
  • Redirect URIs - Enter http://localhost:3000/auth/callback
  • Post-Logout Redirect URIs - Enter http://localhost:3000
  • Resource Connections > Add Resource - Choose “Todo0 Resource App” and mark “todos.read” as your allowed scopes before clicking the Add Connection button.

Once all necessary fields have been filled select Register App.

You’ll see a modal with the Client ID and Client Secret. The xaa.dev testing site also provides credentials for the resource app’s authorization server - the Resource Client ID and Resource Client Secret. Copy all four values. We need to add these to our project.

Get the NestJS project with OAuth and OpenID Connect (OIDC) started

You’ll use a starter note-taking app project written in NestJS. Before you get too excited, remember this is a demo app. While the note-taking features are minimal, it does include built-in authentication.

Open a terminal window and run the following commands to get a local copy of the project in a directory named okta-xaa-project and install dependencies. Feel free to fork the repo to track your changes.

git clone -b starter https://github.com/oktadev/okta-js-xaa-requestor-example.git okta-xaa-project
cd okta-xaa-project
npm ci

Open the project in your IDE. Let’s go over the main components and framework choices so you don’t have to discover everything on your own:

  1. The NestJS project depends on Express as the base engine and uses TypeScript.
  2. Views for the landing page and the notes interface use Nunjucks as the templating engine.
  3. Relies on the openid-client to handle all OAuth handshakes. It’s an OIDC client library for JavaScript runtimes.
  4. There’s a basic interceptor implementation that logs HTTP requests and responses to the console. This way, we can see the token exchange flow.

The app requires a client ID, client secret, resource client ID, and resource client secret to run. Let’s add those to the project.

Rename the .env.example file to .env. It already has variables defined and values added to match the URI of the XAA testing site components. Replace the CLIENT_ID, CLIENT_SECRET, RESOURCE_CLIENT_ID, and RESOURCE_CLIENT_SECRET values with the values from the XAA testing site.

The app should now run, but it still won’t make a successful cross-app access request. Serve the app using the command shown:

npm start

Navigate to http://localhost:3000. You should see a landing page that looks like this:

The notes app landing page with a log in button in the top header

Feel free to sign in. You’re redirected to the XAA testing site’s IdP for the user challenge. Enter the email address and any combination of numbers for the one-time password. You’ll redirect to the notes view and see something like this:

The notes app after signing in. The left nav has notes, the middle section displays the selected note, and the right side shows an empty todo pane

There are no todos yet, and in the IDE’s console we see logging and errors. Each request and response to the XAA testing site’s components has a corresponding log entry. We see the IdP’s redirect with the authorization code, the POST to get tokens along with the request params, and a request to the todo API, which returns a 401 Unauthorized HTTP status code. We need to add the code for the XAA token exchange. Stop serving the app by entering Ctrl+C in the terminal.

Exchanging an ID token for an access token for another app

When you sign in to the note-taking app, the IdP issues an ID token. From here, the XAA token flow is a two-step process:

  1. The note-taking app requests the IDP’s OAuth authorization server to exchange the ID token for a trustworthy intermediary token type, an Identity Assertion JSON Web Token (JWT) also known as ID-JAG, that the todo app recognizes and supports.
  2. The todo app’s OAuth authorization server exchanges the intermediary token and issues an access token.

With the access token in hand, the note-taking app can make resource requests to the todo app’s resource server.

First, we request the trustworthy intermediary token type, the ID-JAG token.

Exchange the ID token for an intermediary ID-JAG token type

In the IDE, open the src/auth/auth.service.ts file. This file contains code for authentication and the OAuth exchange, along with some utility functions. You already have the code to sign in and have the ID token. We’ll continue using the openid-client library for the XAA token exchanges. Find the private helper method exchangeIdTokenForIdJag(). The body of the method has a comment:

// add logic to return an ID-JAG token given the user's ID token

We need to replace the inner workings of this method to return the ID-JAG token instead of an empty promise. No empty promises for us! Our promises are as good as tokens. 👻

Replace the code within the method as shown, then I’ll walk through each code block.

/**
 * Exchange ID token for ID-JAG token (step 1 of ID-JAG flow)
 */
private async exchangeIdTokenForIdJag(
  config: openidClient.Configuration,
  idToken: string,
  authServerUrl: string,
  resourceUrl: string,
  scope: string[],
): Promise<string> {
  const tokenExchangeParams = {
    requested_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
    audience: authServerUrl,
    resource: resourceUrl,
    subject_token: idToken,
    subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
    scope: scope.join(' '),
  };

  const tokenExchangeResponse = await openidClient.genericGrantRequest(
    config,
    'urn:ietf:params:oauth:grant-type:token-exchange',
    tokenExchangeParams,
  );

  return tokenExchangeResponse.access_token;
}

In this first exchange, we call the IdP. The IdP acts as the broker between the two apps as it’s the trusted source.

Let’s step through the key parts of the first code block where we set the token exchange parameters:

  • requested_token_type - we’re asking the IDP for the ID-JAG token
  • audience and resource - the authorization server and the todo API we’re requesting resources from
  • subject_token - the token we’re using for this exchange
  • subject_token_type - the type of the token we’re using for the exchange
  • scopes - the requested scopes, such as reading todos

Once we have all these parameters set, we can call the IdP. The openid-client library has a function for making generic grant requests. We can use it to request the token exchange grant type. While the return value is not an access token, the grant request relies on existing OAuth models that defined the access_token response parameter.

Let’s call the method so we can test it out. Find the comment:

// Step 1: Exchange ID token for ID-JAG token

in the exchangeIdTokenForAccessToken() method.

Add the call to the method like this:

// Step 1: Exchange ID token for ID-JAG token
const idJagToken = await this.exchangeIdTokenForIdJag(
  idpConfig,
  idToken,
  authServerUrl,
  resourceUrl,
  scope,
);

We’re adding configuration information, including the IdP, client ID, and client secret. And we have some other required configuration values pulled from the .env file, such as the servers for the todo app and the scopes.

We’ll get the signed Identity Assertion JWT Authorization grant when the call succeeds. This is a signed token from the IdP, so whenever we exchange it in the next step, the recipient knows it’s trustworthy. Step one complete. ✅

Feel free to start the app and check the console log for your first exchange request. You should see the call to LOG [OAuth HTTP] → POST idp.xaa.dev/token in the console. Below that, you’ll see the token exchange parameters that look something like this:

DEBUG [OAuth HTTP]   body:
    requested_token_type=urn:ietf:params:oauth:token-type:id-jag
    audience=https://auth.resource.xaa.dev
    resource=https://api.resource.xaa.dev
    subject_token=eyJhbGc...IdoRppJyZmV9Q
    subject_token_type=urn:ietf:params:oauth:token-type:id_token
    scope=todos.read
    grant_type=urn:ietf:params:oauth:grant-type:token-exchange

The call to get todos will still fail, but you can see the first exchange request in action! 🚀

Use the ID-JAG token to request an access token for a separate app

With the ID-JAG token in hand, we can now move on to the second exchange, exchanging the ID-JAG intermediary token for an access token to the todo app. We make this exchange with the todo app’s OAuth authorization server. The IdP oversees both the note-taking app and the todo app, and trust domains between the two apps facilitate this flow. Remember, in our first exchange, we had to specify the audience for the ID-JAG token in our request - the todo app.

Back in src/auth/auth.service.ts, find the comment:

// add logic to return an access token given the ID-JAG token

This comment is in the placeholder code for the exchangeIdJagForAccessToken() method.

Replace the placeholder code to make the exchange. Your code will look like this:

/**
  * Exchange ID-JAG token for access token (step 2 of ID-JAG flow)
  */
private async exchangeIdJagForAccessToken(
  config: openidClient.Configuration,
  idJagToken: string,
  scope: string[],
): Promise<string> {
  const jwtBearerParams = {
    assertion: idJagToken,
    scope: scope.join(' '),
  };

  const resourceTokenResponse = await openidClient.genericGrantRequest(
    config,
    'urn:ietf:params:oauth:grant-type:jwt-bearer',
    jwtBearerParams,
  );

  return resourceTokenResponse.access_token;
}

We’re following a similar pattern to the first exchange, with a difference in the grant request. This time, the parameters include an assertion, the ID-JAG token. And we make the grant request to the todo app’s OAuth authorization server with the urn:ietf:params:oauth:grant-type:jwt-bearer grant type. This exchange relies upon a pre-existing spec where one can use a bearer JWT for as a grant type to request an access token. That’s what we’re doing in this step.

Next, we’ll call this method in exchangeIdTokenForAccessToken().

Find the comment:

// Step 2: Exchange ID-JAG token for access token

Because we’re calling a new authorization server, the todo app’s OAuth authorization server, we first need to read the well-known discovery docs. The discovery docs include information about the authorization server, such as the server’s capabilities and endpoints, including the token endpoint. Since we’re authenticating with the todo app’s authorization server, not the IdP, we use the resource app’s credentials here. The todo app’s authorization server recognizes RESOURCE_CLIENT_ID and RESOURCE_CLIENT_SECRET, not your notes app’s credentials. We’ve been using a custom fetch implementation to capture the logging you see, so we must include that implementation in openid-client too. Then make the call to the exchangeIdJagForAccessToken() helper method. Your code will look like this:

// Step 2: Exchange ID-JAG token for access token
const resourceAuthConfig = await openidClient.discovery(
  new URL(authServerUrl),
  resourceClientId,
  resourceClientSecret,
  openidClient.ClientSecretPost(resourceClientSecret ?? ''),
);
resourceAuthConfig[openidClient.customFetch] = loggedFetch;

return this.exchangeIdJagForAccessToken(
  resourceAuthConfig,
  idJagToken,
  scope,
);

Make sure to remove any placeholder implementation. Step two complete. ✅

The code to make a request to the todo API using the bearer token already exists in the project. Let’s try running the app now using npm start.

Inspecting the XAA token exchange

After you authenticate, you’ll see the notes and the todos! 🎉

The notes app with todos listed on the side

In the terminal console, you’ll see each step of the handshake and requests:

  1. Authentication in the notes app with the IdP returning the ID token
  2. Exchanging the ID token for an ID-JAG token with the IDP’s OAuth authorization server
  3. Exchanging the ID-JAG token for an access token with the todo app’s OAuth authorization server
  4. Call the todo app’s resource server (the API)

Feel free to inspect each step of this flow, the request parameters, and the responses.

These steps allow an app to make requests to a third-party app within enterprise systems securely. You can find the completed project in the GitHub repo with instructions to also test on GitHub Codespaces.

Learn more about XAA and elevating identity security using OAuth

I hope you enjoyed this post on making secure cross-app requests for enterprise use cases. If you found this post interesting, I encourage you to check out these links:

Remember to follow us on Twitter and subscribe to our YouTube channel for more exciting content. We also want to hear from you about the topics you’d like to see and any questions you may have. Leave us a comment below!

https://developer.okta.com/blog/2026/02/10/xaa-client
Take User Provisioning to the Next Level with Entitlements

When you work on B2B SaaS apps used by large customer organizations, synchronizing those customers’ users within your software system is tricky! You must synchronize user profile information and the user attributes required for access control management. Customers with large workforces may have thousands of users to manage. They demand a speedy onboarding process, including automated user provisioning from their identity provider!

Managing users across domains is critical to making B2B apps enterprise-scalable. In the Enterprise-Ready and Enterprise-Maturity on-demand workshop series, we tackle the dilemmas faced by developers of SaaS products wanting to scale their apps to enterprise customers. We iterate on a fictitious B2B Todo app more secure and capable for enterprise customers using industry-recognized standards such as OpenID Connect (OIDC) authentication and System for Cross-Domain Identity Management (SCIM) for user provisioning. In this workshop, you build upon a previous workshop introducing automated user provisioning to add support for users’ access management and permissions attributes—their entitlements.

️ℹ️ Note
This post requires Okta Identity Governance (OIG) features in your Okta org. Sign up for a new Integrator Free plan to continue. Posts in the on-demand workshop series 1. How to Get Going with the On-Demand SaaS Apps Workshops 2. Enterprise-Ready Workshop: Authenticate with OpenID Connect 3. Enterprise-Ready Workshop: Manage Users with SCIM 4. Enterprise Maturity Workshop: Terraform 5. Enterprise Maturity Workshop: Automate with no-code Okta Workflows 6. How to Instantly Sign a User Out across All Your Apps 7. Take User Provisioning to the Next Level with Entitlements

This workshop walks you through adding the code to support entitlements in a sample application with three broad sections:

  1. Introduction to the base application, tools, and the development process
  2. See your application’s user and entitlements information in Okta
  3. Use Okta to manage user roles and custom entitlements

If you want to skip to the completed code project for this workshop, you can find it in the entitlements-completed branch on the GitHub repo.

Table of Contents

Manage users at scale using System for Cross-domain Identity Management (SCIM)

The Todo app tech stack uses a React frontend and an Express API backend. For this workshop, you need the following required tooling:

Required tools

  • Node.js v18 or higher
  • Command-line terminal application
  • A code editor/Integrated development environment (IDE), such as Visual Studio Code (VS Code)
  • An HTTP client testing tool, such as Postman or the HTTP Client VS Code extension

VS Code has integrated terminals and HTTP client extensions that allow you to work out of this one application for almost everything required in this workshop. The IDE also supports TypeScript, so you’ll get quicker responses on type errors and help with importing modules.

Follow the instructions in the getting started guide for installing the required tools and serving the Todo application.

How to Get Going with the On-Demand SaaS Apps Workshops

Start your journey to identity maturity for your SaaS applications in the enterprise-ready workshops! This post covers installing and running the base application in preparation for the upcoming workshops.

avatar-avatar-alisa_duncan.jpeg Alisa Duncan

You’ll build upon a prior workshop introducing syncing users across systems using the System for Cross-domain Identity Management (SCIM) protocol.

In this workshop, you’ll dive deeper into automated user provisioning by adding the user attributes required for access management, such as user roles, licensing, permissions, or something else you use to denote what actions a user has access to. The access management attributes of users are known by the generic term, user entitlements. Then, we will continue diving deeper into supporting customized user entitlements using the SCIM protocol.

Before we get going with user entitlements, you’ll first step through the interactive and fun Enterprise-Ready Workshop: Manage Users with SCIM workshop to get the SCIM overview, set up the code and your Okta account, and see how the protocol works. I’ll settle down with a cup of tea and a good book and wait while you learn about SCIM and are ready to continue! 🫖🍵📚

Enterprise-Ready Workshop: Manage users with SCIM

In this workshop, you will add SCIM support to a sample application, so that user changes made in your app can sync to your customer's Identity Provider!

avatar-avatar-semona-igama.jpeg Semona Igama Prepare the Express.js API project

Start from a clean code project by using the SCIM workshop’s completed project code from the scim-workshop-complete branch. I’ll post the instructions using Git, but you can download the code as a zip file if you prefer and skip the Git command.

Get a local copy of the completed SCIM workshop code and install dependencies by running the following commands in your terminal:

git clone -b scim-workshop-complete https://github.com/oktadev/okta-enterprise-ready-workshops.git
cd okta-enterprise-ready-workshops
npm ci

Open the code project in your IDE. We’ll work exclusively within the Express.js API for this project, and the code files for the API are in the okta-enterprise-ready-workshops/apps/api/src directory.

Create a file named entitlements.ts. We’ll define the API routes for user entitlements in the okta-enterprise-ready-workshops/apps/api/src/entitlements.ts file.

Let’s start by hard-coding an API endpoint for /Roles that returns a list of roles. In the entitlements.ts file, add the following code:

import { Router } from 'express';

export const rolesRoute = Router();

rolesRoute.route('/')
.get(async (req, res) => {
  const roles = [
    'Todo-er',
    'Admin'
  ];

  return res.json(roles);
});

Open okta-enterprise-ready-workshops/apps/api/src/scim.ts. We need to register the endpoint in the Express app by including it as part of the SCIM routes.

At the top of the file, import rolesRoutes

import { rolesRoute } from './entitlements';

At the bottom of the file below the existing code, add

scimRoute.use('/Roles', rolesRoute);

to register the endpoint. Let’s make sure everything works!

Serve the Express.js API and test the /Roles SCIM endpoint

In the terminal, start the API by running

npm run serve-api

This command serves the API on port 3333. Launch your HTTP client and call the /Roles endpoint:

GET http://localhost:3333/scim/v2/Roles HTTP/1.1

Do you see a successful response with a list of roles?

HTTP/1.1 200 OK

[
  "Todo-er",
  "Admin"
]

Take a look at the terminal output. You’ll see output recording the GET request!

Terminal output showing the GET request to the /Roles route and a 200OK HTTP response

The project uses Morgan, a library that automatically adds HTTP logging to the Express API. The terminal output includes POST and PUT request payloads, so it’s an excellent way to track the SCIM calls as you work through the workshop.

The npm run serve-api process watches for changes and automatically updates the API, so we don’t need to stop and restart it constantly. But we’re about to make some significant changes. Stop serving the API by entering Ctrl+c in the terminal so we can prepare the database.

Support user roles in the database

The Todo app database needs to support roles; we’ve hardcoded roles so far. It’s time to bring the database to the party. A fancier SaaS app might allow each customer to define their roles. We’ll skip that level of customizability for now and focus on the simplest case. For this workshop, we’ll define supported roles for all Todo app customers instead of allowing role configurations per organization. Taking the position of application roles instead of organization roles makes our database modeling easier. I’ll discuss ways to add per-organization configurability later in the post.

Open okta-enterprise-ready-workshops/prisma/schema.prisma. Add the role model at the end of the file.

model Role {
  id Int @id @default(autoincrement())
  name String
  users User[]
}

A user may have zero or more roles. Update the user model to add roles so that the user model looks like this:

model User {
  id         Int    @id @default(autoincrement())
  email      String
  password   String?
  name       String
  Todo       Todo[]
  org        Org?    @relation(fields: [orgId], references: [id])
  orgId      Int?
  externalId String?
  active     Boolean?
  roles      Role[]
  @@unique([orgId, externalId])
}

With the roles model defined, it’s time to update the database to match the model. We’ll start with a fresh, clean database for this project. In the terminal run

npx prisma migrate reset -f

It helps to have some seed data so we can get going. Here, we’ll define roles available within the Todo app. A user can be a “Todo-er,” “Todo Auditor,” and “Manager.” Open okta-enterprise-ready-workshops/prisma/seed_script.ts and replace the entire file with the code below:

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  const org = await prisma.org.create({
    data: {
      domain: 'gridco.example',
      apikey: '123123'
    }
  });
  console.log('Created org Portal', org);

  // Roles defined by the Todo app
  const roles = [
    { name: 'Todo-er' },
    { name: 'Todo Auditor' },
    { name: 'Manager'}
  ];

  const createdRoles = await Promise.all(
    roles.map(data => prisma.role.create({data}))
  );

  for (const role of createdRoles) {
    console.log('Created role ', role);
  }

  const somnusUser = await prisma.user.create({
    data: {
      name: 'Somnus Henderson',
      email: 'somnus.henderson@gridco.example',
      password: 'correct horse battery staple',
      orgId: org.id,
      externalId: '31',
      active: true
    }
  });
  console.log('Created user Somnus', somnusUser)

 const trinityUser = await prisma.user.create({
    data: {
      name: 'Trinity JustTrinity',
      email: 'trinity@gridco.example',
      password: 'Zion',
      orgId: org.id,
      externalId: '32',
      active: true,
      roles: {
        connect: {
          id: createdRoles.find(r => r.name === 'Todo-er')?.id
        }
      }
    },
  })
  console.log('Created user Trinity', trinityUser)
}

main()
  .then(async () => {
    await prisma.$disconnect()
  })
  .catch(async (e) => {
    console.error(e)
    await prisma.$disconnect()
    process.exit(1)
  })

Save the file and run the npm script in the terminal to seed the database.

npm run init-db

You’ll see console output for each newly created database record. 🎉

Inspect the database records

You can inspect the database records using Prisma Studio. In a separate terminal, run

npx prisma studio

which launches a web interface to view the database. The site URL is usually http://localhost:5555, shown in the terminal output. Open the site in your browser to view the database tables, records, and relationships.

Connect Okta to the SCIM server

The SCIM Client (the identity provider, Okta) makes requests upon objects held by the SCIM Server (the Todo app).

SCIM workflow showing the Identity Provider requests the SCIM server with GET, POST, PUT, and DEL user calls and the SCIM server responds with a standard SCIM interface

First, we need to serve the API so Okta can access it. You’ll use a temporary tunnel for local development that makes localhost:3333 publicly accessible so that Okta, the SCIM client, can call your API, the SCIM server. I’ll include the instructions using an NPM library that we don’t have to install or sign up for, but feel free to use your favorite tunneling system if you have one.

You need two terminal sessions.

In one terminal, serve the API using the command:

npm run serve-api

In the second terminal, you’ll run the local tunnel. Run the command:

npx localtunnel --port 3333

This creates a tunnel for the application serving on port 3333. The console output displays the tunnel URL in the format https://{yourTunnelSubdomain}.loca.lt, such as:

your URL is: https://awesome-devs-club.loca.lt

You’ll need this tunnel URL to configure the Okta application.

Create an Okta SCIM application for entitlements governance

In the prerequisite SCIM workshop, you added a SCIM application in Okta to connect to the Todo app. We must do something similar to connect SCIM with entitlements support.

Sign into your Okta Integrator Free account. In the Admin Console, navigate to Applications > Applications. Press the Browse App Catalog button to create a new Okta SCIM application.

In the search bar, search for “(Header Auth) Governance with SCIM 2.0” and select the app. Press Add Integration.

You’ll see a configuration view with two tabs. Press Next on the General settings tab. Leave default settings on the Sign-On Options tab and press Done.

You’ll navigate to your newly created Okta application to add specific configurations about the Todo app.

First, you need to enable Identity Governance. Navigate to the General tab and find the Identity Governance section. Press Edit to select Enabled for Governance Engine. Remember to Save your change.

Navigate to the Provisioning tab and press the Configure API Integration button. Check the Enable API integration checkbox—two more form fields display.

  • In Base URL field, enter https://{yourTunnelSubdomain}.loca.lt/scim/v2.

    It will look like https://awesome-devs-club.loca.lt/scim/v2

  • In the API Token field, enter Bearer 123123

press Save.

The Provisioning tab has more options to configure within the Settings side nav.

Navigate to the To App option and press Edit.

  • Enable Create Users
  • Enable Update User Attributes
  • Enable Deactivate Users

Press Save.

Import users from the todo app into Okta. Navigate to the Import tab and press the Import Now button. Okta discovers users in your app and tries to match them with users already defined in Okta. A dialog shows Okta discovered the two users you added using the DB script. Select both users and press the Confirm Assignments to confirm the assignments.

You’ll see the imported users in the Assignments tab. But what about entitlements? They’re coming right up!

Stop the tunnel and the API using the Ctrl+c command in the terminal windows. We’ll make some changes to the API that won’t automatically reflect in the local tunnel, so we’ll get all our entitlements changes made and resynchronize with Okta.

SCIM schemas and resources

In the first SCIM workshop, you learned about SCIM’s User resource and built out operations around the user. You updated only a handful of user properties in the workshop, but SCIM is way more powerful thanks to its superpower – extensibility. ✨ User is not the only resource type defined in SCIM.

A Resource represents an object SCIM operates on, such as a user or group. SCIM identified core properties each Resource must define, such as id and a link to the resource’s schema definition. From there, a user extends from the core properties and adds attributes specific to the object, such as adding userName and their emails. A standard published schema exists for all those user-specific attributes within the SCIM spec. You can continue extending resources as needed to represent new resources, such as another SCIM standard-defined schema for Enterprise User.

Class diagram representing core Resource properties, User class extending from core Resource adds username and emails properties. The Enterprise User class extends from User adds department and costCenter properties. Group class extends from core Resource and adds displayName and members properties. Other class extends from core Resource demonstrating new resource representations.

What’s an example resource other than a user or group? If you said “role” or an “entitlement,” you’re correct! Those resource types must have an id and schemas. Here, Okta used SCIM’s extensibility to define a new resource type.

Class diagram representing core Resource properties. The User, Group, OktaRole, and Other class extends from core Resource.

Okta defines a schema for the Role representation. We can use the schema to ensure we conform to the definition.

Harness TypeScript to conform to SCIM schemas

We can define an interface to model the Role representation. Add a new file to the project named okta-enterprise-ready-workshops/apps/api/src/scim-types.ts and open it up in the IDE. This file will contain the SCIM schema definitions, such as the SCIM core Resource. Each interface defines required and optional properties and the property’s type.

Copy and paste the first interface for the SCIM resource into the scim-types.ts file.

export interface IScimResource {
  id: string;
  schemas: string[];
  meta?: IMetadata;
}

A SCIM resource has an optional meta property containing the resource’s metadata. Your IDE shows errors, so we can fix this by adding the IMetadata definition to the file below the IScimResource:

export interface IMetadata {
  resourceType: RESOURCE_TYPES;
  location?: string;
}

You’ll have a new error for RESOURCE_TYPES. We’ll fix it soon.

Now, on to the Okta Role representation. The role representation extends from the core SCIM resource and adds extra properties. Okta’s schema overlaps with the SCIM standard User roles field, which includes a property for display text. Define the interface and add it to IMetadata below.

export interface IOktaRole extends IScimResource{
  displayName: string;
}

The IOktaRole extends from the core IScimResource interface and adds a new required property, displayName. Each resource requires a schema, a Uniform Resource Namespace (URN) string. Instead of repeatedly typing the string for each role resource, define it below the IOktaRole interface for reusability

export const SCHEMA_OKTA_ROLE = 'urn:okta:scim:schemas:core:1.0:Role';

Let’s fix the RESOURCE_TYPES error. Below the SCHEMA_OKTA_ROLE constant, add the following:

export type RESOURCE_TYPES = 'Role';

You can use the IOktaRole interface in the /Roles endpoint to ensure the response matches the expected structure. Open okta-enterprise-ready-workshops/apps/api/src/entitlements.ts, and update the code to use the interface.

import { Router } from 'express';
import { IOktaRole, SCHEMA_OKTA_ROLE } from './scim-types';

export const rolesRoute = Router();

rolesRoute.route('/')
.get(async (req, res) => {
  const roles: IOktaRole[] = [{
    schemas: [SCHEMA_OKTA_ROLE],
    id: 'one',
    displayName: 'Todo-er'
  }];

  return res.json(roles);
});

Why use TypeScript and interfaces?

TypeScript, a superset of JavaScript, supports type safety. Type safety means we’ll catch errors within the IDE or at build time instead of getting caught by surprise with a runtime error. Here, we state the roles array is of type IOktaRole[]. Try commenting out the required schemas property. You’ll see an error in an IDE that supports TypeScript or when you try to serve the API as console output. We can use type safety to ensure we meet the expectations of required SCIM properties in our calls.

IDE and terminal showing the type error when `schemas` is commented out

Every code change deserves a quick check. Serve the API and double check everything still works for you when you make the HTTP call to

GET http://localhost:3333/scim/v2/Roles HTTP/1.1

Do you see the one ‘Todo-er’ role in the response? ✅

SCIM list response

We return the array of Okta roles directly in the API response, but this format doesn’t match SCIM list responses. SCIM has a structured response format for lists and a defined schema. This way, SCIM structures all communication between the client and the server so each side knows how to format and parse data.

Let’s define the ListResponse interface. Open okta-enterprise-ready-workshops/apps/api/src/scim-types.ts. The list response contains standard information supporting pagination, the schema for the list response, and the list of objects. Add the interface to the file. I like to organize my definitions, so I added the code between the IOktaRole interface and SCHEMA_OKTA_ROLE string constant.

export interface IListResponse {
  schemas: string[];
  totalResults: number;
  startIndex: number;
  itemsPerPage: number;
  Resources: IOktaRole[];
}

The list response also has a schema URN. Create a constant for this string as you did for the Okta role and add it after the role schema string.

export const SCHEMA_LIST_RESPONSE = 'urn:ietf:params:scim:api:messages:2.0:ListResponse';

The API response must match the list format. Open okta-enterprise-ready-workshops/apps/api/src/entitlements.ts and add IListResponse and SCHEMA_LIST_RESPONSE to the imports from the scim-types file:

import { IListResponse, IOktaRole, SCHEMA_LIST_RESPONSE, SCHEMA_OKTA_ROLE } from './scim-types';

Change rolesRoute response to use the list response:

rolesRoute.route('/')
.get(async (req, res) => {
  const roles: IOktaRole[] = [{
    schemas: [SCHEMA_OKTA_ROLE],
    id: 'one',
    displayName: 'Todo-er'
  }];

  const listResponse: IListResponse = {
    schemas: [SCHEMA_LIST_RESPONSE],
    totalResults: roles.length,
    itemsPerPage: roles.length,
    startIndex: 1,
    Resources: roles
  };

  return res.json(listResponse);
});

Double-check everything still works. Send the HTTP request to your API. ✅

GET http://localhost:3333/scim/v2/Roles HTTP/1.1
Return database-defined roles in the SCIM /Roles endpoint

Each role has an ID and a name. We can retrieve the roles from the database and populate the /Roles response.

Open okta-enterprise-ready-workshops/apps/api/src/entitlements.ts and make the changes to retrieve the roles from the database and map the database results to the IOktaRole properties. You’ll need to import some dependencies, so ensure the import statements match. The SCIM ListResponse supports pagination, so we’ll add the required code to consider the query parameters.

import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
import { IListResponse, IOktaRole, SCHEMA_LIST_RESPONSE, SCHEMA_OKTA_ROLE } from './scim-types';

const prisma = new PrismaClient();

export const rolesRoute = Router();

rolesRoute.route('/')
.get(async (req, res) => {
  const startIndex = parseInt(req.query.startIndex as string ?? '1');
  const recordLimit = parseInt(req.query.recordLimit as string ?? '100');

  const roles = await prisma.role.findMany({
    take: recordLimit,
    skip: startIndex - 1
  });

const listResponse: IListResponse = {
    schemas: [SCHEMA_LIST_RESPONSE],
    totalResults: roles.length,
    startIndex,
    itemsPerPage: recordLimit,
    Resources: roles.map(role => ({
      schemas: [SCHEMA_OKTA_ROLE],
      id: role.id.toString(),
      displayName: role.name
    }))
  };

  return res.json(listResponse);
});

Run a quick check to ensure everything still works. Serve the API and call the /Roles endpoint using your HTTP client. ✅

You should see three roles matching the roles in the database. 🎉

SCIM resource types

We implemented the /Roles endpoint and discussed how SCIM defines a resource. But how would the SCIM client know about this Okta Role type? Enter discovery—learning about a SCIM server’s capabilities and supported objects such as resources!

SCIM clients and servers communicate about the types of resources through a standard endpoint, the/ResourceType endpoint. SCIM clients call the endpoint to discover what resources they can expect. The endpoint returns a SCIM list response outlining resources. You can add every resource type used, including the standard User and EnterpriseUser resources, but Okta expects resource definitions only for custom types.

First, we’ll create the interface for the ResourceType and define some strings. Open okta-enterprise-ready-workshops/apps/api/src/scim-types.ts. Add the interface for IResourceType above the IListResponse interface.

export interface IResourceType {
  id?: string;
  schemas: string[];
  name: string; 
  description?: string;
  endpoint: string;
  schema: string; 
  meta: IMetadata;
}

Notice the IResourceType doesn’t extend from the IScimResource interface. For example, the SCIM standard doesn’t require id for a resource type. Since the SCIM standard treats ResourceType as an exception case of Resource, we defined it separately without the relation instead of extending from IScimResource.

When following the SCIM protocol, responses that list values, such as the list of roles or resource types, use the SCIM list response format.

The IListResource interface must support IOktaRole and IResourceType. Using generics and union types, we can support different list response objects . Update the IListResource to match the code below.

export interface IListResponse<T extends IScimResource | IResourceType> {
  schemas: string[];
  totalResults: number;
  startIndex: number;
  itemsPerPage: number;
  Resources: T[];
}

You’ll see errors in the IDE and, if you’re running the API, within the console output. No worries; we’ll fix those errors soon!

Resource types have a schema URN and use “ResourceType” as the resourceType string in the metadata. Add SCHEMA_RESOURCE_TYPE and edit RESOURCE_TYPES so your string constants section looks like the code below.

export const SCHEMA_OKTA_ROLE = 'urn:okta:scim:schemas:core:1.0:Role';
export const SCHEMA_LIST_RESPONSE = 'urn:ietf:params:scim:api:messages:2.0:ListResponse';
export const SCHEMA_RESOURCE_TYPE = 'urn:ietf:params:scim:schemas:core:2.0:ResourceType';
export type RESOURCE_TYPES = 'Role' | 'ResourceType';

Open okta-enterprise-ready-workshops/apps/api/src/entitlements.ts. Let’s fix the IListResponse error for the /Roles endpoint and specify the object type in the list, the IOktaRole type. The code building out the list changes to

  const listResponse: IListResponse<IOktaRole> = {
    schemas: [SCHEMA_LIST_RESPONSE],
    totalResults: roles.length,
    startIndex,
    itemsPerPage: recordLimit,
    Resources: roles.map(role => ({
      schemas: [SCHEMA_OKTA_ROLE],
      id: role.id.toString(),
      displayName: role.name
    }))
  };

You shouldn’t see errors anymore! 🎉

We have a new endpoint to add. Update the imports from the ./scim-types file and declare a new route for resource types.

import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
import {
  IListResponse, IOktaRole, IResourceType, SCHEMA_LIST_RESPONSE, SCHEMA_OKTA_ROLE, SCHEMA_RESOURCE_TYPE
} from './scim-types';

const prisma = new PrismaClient();
export const rolesRoute = Router();
export const resourceTypesRoute = Router();


// existing rolesRoute code below

Then create the /ResourceTypes route by adding the code below the rolesRoute

resourceTypesRoute.route('/')
.get((req, res) => {
  const resourceTypes: IResourceType[] = [{
    schemas: [SCHEMA_RESOURCE_TYPE],
    id: 'Role',
    name: 'Role',
    endpoint: '/Roles',
    description: 'Roles you can set on users of Todo App',
    schema: SCHEMA_OKTA_ROLE,
    meta: {
      resourceType: 'ResourceType'
    }
  }];

  const resourceTypesListResponse: IListResponse<IResourceType> = {
    schemas: [SCHEMA_LIST_RESPONSE],
    totalResults: resourceTypes.length,
    startIndex: 1,
    itemsPerPage: resourceTypes.length,
    Resources: resourceTypes
  };

  return res.json(resourceTypesListResponse);
});

Next, you must register the /ResourceTypes route in the API. Open okta-enterprise-ready-workshops/apps/api/src/scim.ts.

Update the import to include resourceTypesRoute

import { resourceTypesRoute, rolesRoute } from './entitlements';

Add the /ResourceTypes endpoint to the end of the file. You should have two routes defined.

scimRoute.use('/Roles', rolesRoute );
scimRoute.use('/ResourceTypes', resourceTypesRoute);

Double-check your new route by starting the API if it’s not running. Use your HTTP client to make the call

GET http://localhost:3333/scim/v2/ResourceTypes HTTP/1.1

If you see a response with the Okta role resource type, the API call works as expected! ✅

Add roles to the SCIM Users endpoints

Let’s add roles to the existing user calls. We want to reflect a user’s roles in Okta within the Todo app, so the GET and POST /Users calls must support roles. Near the top of the scim.ts file, find IUserSchema interface.

Update the interface to add the roles property:

interface IUserSchema {
  schemas: string[];
  userName?: string;
  id?: string;
  name?: {
    givenName: string;
    familyName: string;
  };
  emails?: {primary: boolean, value: string, type: string}[];
  displayName?: string;
  locale?: string;
  meta?: {
    resourceType: string;
  }
  externalId?: string;
  groups?: [];
  password?: string;
  active?: boolean;
  detail?: string;
  status?: number;
  roles?: {value: string, display: string}[];
}

The User SCIM schema defines roles property as a list of objects that may contain properties named value and display, among others. Okta uses these properties for role data.

Update the SCIM add users call to include roles

The first route defined is the POST /Users route definition. You need to add roles when saving to the database. Find the comment

// Create the User in the database

and update the database command and the as shown.

// Create the User in the database
const user = await prisma.user.create({
  data: {
    org : { connect: {id: ORG_ID}},
    name,
    email,
    password,
    externalId,
    active,
    roles: {
      connect: newUser.roles?.map(role => ({id: parseInt(role.value)})) || []
    }
  },
  include: {
    roles: true
  }
});

console.log('Account Created ID: ', user.id);

One more place to update in the POST /Users call. We need to return the roles in the response. Right below the console.log() update the userResponse to

userResponse = { ...defaultUserSchema,
  id: `${user.id}`,
  userName: user.email,
  name: {
    givenName,
    familyName
  },
  emails: [{
    primary: true,
    value: user.email,
    type: "work"
  }],
  displayName: name,
  externalId: user.externalId,
  active: user.active,
  roles: user.roles.map(role => ({display: role.name, value: role.id.toString()}))
};
Add roles when getting a list of users in SCIM

Continuing to the GET /Users call, search for the code to find users in the database

await prisma.user.findMany({...});

to add roles to the select argument.

const users = await prisma.user.findMany({
  take: recordLimit,
  skip: startIndex,
  select: {
    id: true,
    email: true,
    name: true,
    externalId: true,
    active: true,
    roles: true
  },
  where
});

The GET /Users response also needs roles, so update the

usersResponse['Resources'] = users.map(user => {...});

like this.

usersResponse['Resources'] = users.map(user => {
  const [givenName, familyName] = user.name.split(" ");
  return {
    ...defaultUserSchema,
    id: user.id.toString(),
    userName: user.email,
    name: {
      givenName,
      familyName
    },
    emails: [{
      primary: true,
      value: user.email,
      type: 'work'
    }],
    displayName: user.name,
    externalId: user.externalId,
    active: user.active,
    roles: user.roles.map(role => ({display: role.name, value: role.id.toString()}))
  }
});
Update the response for an individual user

On to the next call, GET /Users/:userId. We need to add roles to the

const user = await prisma.user.findFirst({...});

database command. Update it to match the code below.

const user = await prisma.user.findFirst({
  select: {
    id: true,
    email: true,
    name: true,
    externalId: true,
    active: true,
    roles: true
  },
  where: {
    id,
    org: {id: ORG_ID},
  }
});

Then, find the comment

// If no response from DB, return 404

to update the userResponse object inside the if statement. Update the userResponse to match the code shown.

userResponse = {
  ...defaultUserSchema,
  id: id.toString(),
  userName: email,
  name: {
    givenName,
    familyName
  },
  emails: [{
    primary: true,
    value: email,
    type: 'work'
  }],
  displayName: name,
  externalId: user.externalId,
  active: user.active,
  roles: user.roles.map(role => ({display: role.name, value: role.id.toString()}))
} satisfies IUserSchema;
Update the /Users call so SCIM clients can set their roles

Another endpoint down, but there’s one more left, the PUT /Users/:userId.

Find the code

const { name, emails } = updatedUserRequest;

and change it to the following code so we can work with the user’s updated roles and save the changes in the database.

const { name, emails, roles } = updatedUserRequest;

const updatedUser = await prisma.user.update({
  data: {
    email: emails.find(email => email.primary).value,
    name: `${name.givenName} ${name.familyName}`,
    roles: {
      set: roles?.map(role => ({id: parseInt(role.value)})) || []
    }
  },
  where : {
    id
  },
  include: {
    roles: true
  }
});

Lastly, we need to update the response from the PUT /Users/:userId call. Update the userResponse object to look like this.

userResponse = {
  ...defaultUserSchema,
  id: id.toString(),
  userName: updatedUser.email,
  name: {
    givenName,
    familyName
  },
  emails: [{
    primary: true,
    value: updatedUser.email,
    type: 'work'
  }],
  displayName: updatedUser.name,
  externalId: updatedUser.externalId,
  active: updatedUser.active,
  roles: updatedUser.roles?.map(role => ({display: role.name, value: role.id.toString()}))
} satisfies IUserSchema;

Serve the API if you aren’t running it using npm run serve-api. Let’s make an HTTP call to get all users to double-check our work.

GET http://localhost:3333/scim/v2/Users
Authorization: Bearer 123123

You will see the list of users. Each user object has a roles and entitlements property. ✅

Entitlements discovery in Okta

What can Okta do with user entitlements? Okta can discover defined entitlements, such as the roles you define for the Todo app, and applies existing roles on users. Now that you have all the endpoints needed for a SCIM client to discover resources held by a SCIM server, you can see this in action on Okta.

You’ll need to serve the API and create a local tunnel. Serve the API using the npm run serve-api command. In a second terminal window, run npx localtunnel --port 3333. Take note of your tunnel URL.

Sign into your Okta Developer Edition account. Navigate to Applications > Applications and select the “(Header Auth) Governance with SCIM 2.0” app. Navigate to the Provisioning tab and select Integration. Press Edit.

Update the Base URL field by replacing the tunnel URL with your new tunnel URL. Make sure you keep the /scim/v2 path. Your base URL might look something like https://beep-bop-boop.loca.lt/scim/v2. Press Save.

Updating the API integration kicks off a discovery process. Okta automatically looks for roles as a possible entitlement type. It then matches the roles it discovers for the Todo application and matches them again with roles defined on the users. You can see Okta working by looking at the terminal window serving the API. You can see the calls Okta makes by inspecting the HTTP requests and their payloads written to the console. 🔍

Make sure to keep the API running! There’s more work to do here!

Navigate to the Governance tab. The tab you see is Entitlements. Do you see Role in the sidenav below the Search input? If not, hang tight. Because an app may have many defined entitlements, Okta starts a background job to discover roles asynchronously. It could take up to 10 minutes for the roles to populate.

Eventually, you’ll see Role; when you select it, you’ll see metadata about it, such as the variable name, data type, and description. We also see the values: “Manager,” “Todo Auditor,” and “Todo-er.”

Governance tab with roles discovered by Okta

You can define policies for users that automatically assign their entitlements when adding them to this integration app. While that’s pretty nifty, this post focuses on building out the SCIM endpoints for entitlements, so I’ll include links to resources that explain this feature in more detail at the end of the post.

Press < Back to application to return to the SCIM Okta app.

Syncing user entitlements

When you use an identity provider, you want that system to be the source of truth for managing the users’ identities and access levels. You want to set the roles you defined for the Todo app onto users within Okta. That would be pretty sweet, right?

Since we last ran our user import with hardcoded roles, let’s ensure we’ve synchronized everything from the starting state of the application before we start managing with Okta.

Within the SCIM application tab, navigate to Import and press the Import Now button. Okta scans the users in the todo app, but since there are no new users, there’s no confirmation process. The user scan synced the existing users and the roles!

Navigate to Assignments. Each user has a vertical 3-dot menu icon to display a context menu allowing you to Edit user assignment, View access details **, and **Unassign. Find “Trinity” and **View access details ** on them. A panel shows you Trinity’s role pre-assigned in the Todo app. 🎉 Exit the side panel by clicking outside the side panel.

Let’s assign a new role to “Somnus” using Okta. Open the context menu for “Somnus” and View access details. Press the Edit access button. You’ll see a page titled Edit access. Press the Customize entitlements button. You’ll see a warning followed by a section called Custom Entitlements.

You’ll see Role and a dropdown list with values. Select a role, such as “Todo-er,” and press Save to add the role to the user.

But how about the Todo app? Take a look at the terminal output where you’re serving the API. The HTTP call tracing shows a PUT request on the user adding the role. Can you see the role of the user in the database? You can check it out by opening another terminal window, running npx prisma studio, and navigating to the website. ✅

You can now use Okta to manage user roles centrally and automatically update the user’s grants!

Stop serving the local tunnel and API for this next section.

Schema discovery for custom entitlements

What if we have something other than roles in the application? Can SCIM support custom entitlement strategies? SCIM is extensible, meaning it has the structure for custom schemas and extends beyond the core resources. A SCIM server can publish a custom schema if it defines custom resource types.

Let’s say you have user roles but want to add a custom entitlement, such as licenses, profiles, or something else. Let’s walk through the example where we want to add a custom entitlement. We will call this “Characteristic,” such as whether the user is tall. We know Trinity is tall, so it’s logical to note their tallness as part of their user attributes.

SCIM clients must discover resources through schemas. So, we first need to define the schema describing “Characteristics.” Note that I came up with “Characteristics” as the name of this attribute, but you will need to change it for your user entitlements model, whether it be some sort of permissions system or something else. Custom schemas can extend from an existing schema, such as Okta’s entitlement schema, which tracks data as a key-value pair, and add our own flavoring to it.

In the IDE, open okta-enterprise-ready-workshops/apps/api/src/scim-types.ts.

Add new schema URNs after the SCHEMA_OKTA_ROLE definition towards the end of the file:

export const SCHEMA_OKTA_ENTITLEMENT = 'urn:okta:scim:schemas:core:1.0:Entitlement';
export const SCHEMA_CHARACTERISTIC = 'urn:bestapps:scim:schemas:extension:todoapp:1.0:Characteristic';

We defined a new schema URN for the characteristic SCIM resource. Following naming conventions for extension schemas, we substituted our company name (Best Apps) and added the app’s name (Todo app). The format looks like this

urn:<Company name>:scim:schemas:extension:<App name>:1.0:<Custom entitlement>

Right now, there’s a custom TypeScript type for RESOURCE_TYPES. Since we’ll have custom schemas as a resource type, update the code.

export type RESOURCE_TYPES = 'Role' | 'ResourceType' | 'Schema';

SCIM defines required and optional attributes to describe a schema resource. We’ll define the interfaces for a schema resource. Add the following interfaces to the scim-types.ts file. I added mine after the other interfaces and before the URNs.

export interface ISchema {
  id: string;
  name?: string;
  description?: string;
  attributes: IAttribute[];
  meta: IMetadata;
}

export interface IAttribute {
  name: string;
  description: string;
  type: string;
  multiValued: boolean;
  required: boolean;
  caseExact: boolean;
  mutability: string;
  returned: string;
  uniqueness: string;
}

Characteristic is a unique resource type because it’s a new, custom type extending from an existing schema. We must explicitly show this relationship for consuming SCIM clients, like Okta. Find the IResourceType interface. We’ll add a new optional property, schemaExtensions and inline the type definition.

export interface IResourceType {
  id?: string;
  schemas: string[];
  name: string;
  description?: string;
  endpoint: string;
  schema: string;
  schemaExtensions?: {schema: string, required: boolean}[];
  meta: IMetadata;
}

SCIM clients expect a list of schemas that you offer in the SCIM server. You might’ve guessed what that means. You must wrap all the schemas in a SCIM ListResponse. Find IListResponse and add ISchema as a supported type. The IListResponse interface changes to:

export interface IListResponse<T extends IScimResource | IResourceType | ISchema> {
  schemas: string[];
  totalResults: number;
  startIndex: number;
  itemsPerPage: number;
  Resources: T[];
}

Finally, we define what a characteristic attribute looks like by adding the interface shown below.

export interface ICharacteristic extends IScimResource {
  type: string;
  displayName: string;
}

With all the types and interfaces defined, it’s time to write the code for the route. Open okta-enterprise-ready-workshops/apps/api/src/entitlements.ts.

Update the import array from ./scim-types.ts:

import {
  ICharacteristic,
  IListResponse,
  IOktaRole,
  IResourceType,
  ISchema,
  SCHEMA_CHARACTERISTIC,
  SCHEMA_LIST_RESPONSE,
  SCHEMA_OKTA_ENTITLEMENT,
  SCHEMA_OKTA_ROLE,
  SCHEMA_RESOURCE_TYPE
} from './scim-types';

Below the other route definitions, add two new route definitions.

export const schemasRoute = Router();
export const characteristicsRoute = Router();

Now, it’s time to define the /Schemas route. The /Schemas endpoint returns a list of schemas. You can return schemas for all the resources you use, even for User, but Okta allows us to skip the strict SCIM requirements and only return custom schemas. The custom schema we’ll return has metadata about a user characteristic, specifically whether the user is tall. Add the following code at the end of the file.

schemasRoute.route('/')
  .get((_, res) => {
    const characteristic: ISchema = {
      id: SCHEMA_CHARACTERISTIC,
      name: 'Characteristic',
      description: 'User characteristics for entitlements',
      attributes: [{
        name: 'is_tall',
        description: 'Profile entitlement extension for tallness factor',
        type: 'string',
        multiValued: false,
        required: false,
        mutability: 'readWrite',
        returned: 'default',
        caseExact: false,
        uniqueness: 'none'
      }],
      meta: {
        resourceType: 'Schema',
        location: `/v2/Schemas/${SCHEMA_CHARACTERISTIC}`
      }
    };

    const schemas = {
      schemas: [SCHEMA_LIST_RESPONSE],
      totalResults: 1,
      startIndex: 1,
      itemsPerPage: 1,
      Resources: [
        characteristic
      ]
    };

    return res.json(schemas);
  });

And we must define a route for /Characteristics, in the same way one exists for /Roles. We won’t worry about updating the database for this as I don’t want to detract from the SCIM concepts. We’ll hardcode the characteristic for now so you can see what this looks like within Okta. Feel free to add the required code to connect it to the database as homework. 🏆 Add the following code below the schemas route:

characteristicsRoute.route('/')
  .get((_, res) => {
    const characteristicsListResponse: IListResponse<ICharacteristic> = {
      schemas: [
        SCHEMA_OKTA_ENTITLEMENT,
        SCHEMA_CHARACTERISTIC
      ],
      totalResults: 1,
      startIndex: 1,
      itemsPerPage: 1,
      Resources: [{
        schemas: [SCHEMA_CHARACTERISTIC],
        type: "Characteristic",
        id: "is_tall",
        displayName: "This user is so tall"
      }]
    };

    return res.json(characteristicsListResponse);
  });

Notice the ID is the string “is_tall”. I modeled it to look like an enum here so that it’s distinct from roles, but IDs in your system may be a UUID or an integer.

Lastly, we must add the new characteristic resource type to the /ResourceTypes response so that Okta knows the resource exists. Find the resourceTypes.route('/') definition and update the resourceTypes array to include both roles and characteristics.

  const resourceTypes: IResourceType[] = [{
    schemas: [SCHEMA_RESOURCE_TYPE],
    id: 'Role',
    name: 'Role',
    endpoint: '/Roles',
    description: 'Roles you can set on users of Todo App',
    schema: SCHEMA_OKTA_ROLE,
    meta: {
      resourceType: 'ResourceType'
    }
  },
  {
    schemas: [SCHEMA_RESOURCE_TYPE],
    id: 'Characteristic',
    name: 'Characteristic',
    endpoint: '/Characteristics',
    description: 'This resource type is user characteristics',
    schema: 'urn:okta:scim:schemas:core:1.0:Entitlement',
    schemaExtensions: [
      {
        schema: SCHEMA_CHARACTERISTIC,
        required: true
      }
    ],
    meta: {
      resourceType: 'ResourceType'
    }
  }
];

Now, we must register the routes in the API. Open okta-enterprise-ready-workshops/apps/api/src/scim.ts. At the top of the file, update the imports from ./entitlements to

import { characteristicsRoute, resourceTypesRoute, rolesRoute, schemasRoute } from './entitlements';

At the end of the file, add the code to register the /Schemas and /Charactertistics routes to the API.

scimRoute.use('/Schemas', schemasRoute);
scimRoute.use('/Characteristics', characteristicsRoute);

Serve the API by running npm run serve-api in a terminal window. In a second terminal window, run npx localtunnel --port 3333 to create a local tunnel for the API. Keep track of the tunnel URL.

Back in the Okta Admin console, navigate to Applications > Applications and open the SCIM with governance Okta app. Navigate to Provisioning > Integration. Press Edit and update the Base URL using the new tunnel URL. Don’t forget to keep the /scim/v2 at the end of the URL. The URL should look something like

https://{yourTunnelSubdomain}.loca.lt/scim/v2

Press Save.

Okta discovers schemas and resource types when updating the provisioning configuration. If you look at the HTTP call tracing in the terminal window serving the API, you’ll see that Okta made a GET request to both /Schemas and /Characteristics.

Navigate to the Governance. Characteristic may take 10-15 minutes to populate, but you’ll see the display name and value when it does. Go < Back to application and navigate to Assignments. Open the user context menu for “Trinity” by pressing the three vertical dots icon menu and opening View entitlements. Press Edit and Customize entitlements to add the is_tall user characteristic. Save the changes and navigate back to the Okta SCIM app.

Check out the terminal serving the API for the HTTP call tracing. You’ll see a PUT request on Trinity adding the new characteristic. The field goes into the core SCIM User entitlements property. Check it out by inspecting the HTTP tracing in the console output. ✅

Multi-tenant use cases for entitlements

In this workshop, we defined roles for the entire Todo app. But what if your SaaS app supports tenant-configurable roles? You must make structural changes to the Todo app database to support organization roles. Notice that an organization has a unique API key, and we included this API as a Bearer token value in the Authorization header. All the SCIM calls from Okta can target a specific organization in the Todo app, including the organization’s custom roles.

️ℹ️ Note
We used an API key for demonstration purposes, but we recommend using OAuth to secure the calls from Okta to your API for production applications. Use SCIM to manage user provisioning and entitlements

In this workshop, you dived deeper into SCIM and learned about resources and schemas. You also synced users and their pre-existing entitlements from the Todo app and provisioned users within Okta. I hope you enjoyed this workshop and have ideas for using it for your SaaS applications! Check out the Identity Governance help docs to learn about Okta Identity Governance.

You can find the completed code project in the entitlements-workshop-completed branch within the GitHub repo.

If you want to learn more about what it means to be enterprise-ready and to have enterprise maturity, check out the other workshops in this series

Posts in the on-demand workshop series 1. How to Get Going with the On-Demand SaaS Apps Workshops 2. Enterprise-Ready Workshop: Authenticate with OpenID Connect 3. Enterprise-Ready Workshop: Manage Users with SCIM 4. Enterprise Maturity Workshop: Terraform 5. Enterprise Maturity Workshop: Automate with no-code Okta Workflows 6. How to Instantly Sign a User Out across All Your Apps 7. Take User Provisioning to the Next Level with Entitlements

Want to learn about more exciting topics? Let us know by commenting below. To get notified about exciting new content, follow us on Twitter and subscribe to our YouTube channel.

https://developer.okta.com/blog/2026/01/21/user-entitlements-workshop
Introducing xaa.dev: A Playground for Cross App Access

AI agents are quickly becoming part of everyday enterprise development. They summarize emails, coordinate calendars, query internal systems, and automate workflows across tools.

But once an AI agent needs to access an enterprise application on behalf of a user, things get complicated.

How do you securely let an AI-powered app act for a user without exposing credentials, spamming consent prompts, or losing administrative control?

This is the problem Cross App Access (XAA) is designed to solve.

Today, we’re introducing xaa.dev, a free, open playground that lets you explore Cross App Access end-to-end. No local setup. No infrastructure to provision. Just a working environment where you can see the protocol in action.

xaa.dev playground homepage showing the Cross App Access flow

Note: xaa.dev is currently in beta. We’re actively developing new features for the next release, and your feedback helps shape what comes next.

Table of Contents

What is Cross App Access?

Cross App Access refers to a typical enterprise pattern: one application accesses another application’s resources on behalf of a user.

For example:

  • An internal AI assistant fetching updates from a project management system
  • A workflow engine booking meetings through a calendar API
  • An agent querying internal data sources to complete a task

Traditionally, OAuth consent flows handle this. That approach works well for consumer-based apps, but it creates friction in enterprise environments where organizations require workforce oversight:

  • Applications and their access levels are centrally managed
  • IT teams need visibility into trust relationships
  • Access must be revocable without user involvement

Cross App Access shifts responsibility from end users to the enterprise identity layer.

Instead of prompting users for consent, the Identity Provider (IdP) issues a signed identity assertion called an ID-JAG (Identity JWT Authorization Grant). This assertion cryptographically represents the user and the requesting application. Resource applications trust the IdP’s assertion and issue access accordingly.

The result:

  • No interactive consent screens making application access seamless for employees
  • Clear, auditable trust boundaries
  • Complete administrative control over app-to-app access

For a deeper dive into why this matters for enterprise AI, read more about Cross App Access in this post:

Integrate Your Enterprise AI Tools with Cross-App Access

Manage user and non-human identities, including AI in the enterprise with Cross App Access

avatar-avatar-semona-igama.jpeg Semona Igama The problem: testing XAA is hard

XAA is built on an emerging OAuth extension called the Identity Assertion JWT Authorization Grant – an IETF draft that Okta, along with public and industry contributors, has been actively contributing to. It’s powerful, but it’s also new, and new protocols need experimentation.

Here’s the challenge: to test XAA locally, you’d need to spin up:

  • An Identity Provider (IdP)
  • An Authorization Server for the resource application
  • The resource API itself
  • A requesting application (the agent or client app)

That’s hours (or days) of configuration before you can even see a single token exchange. Most developers give up before getting to the interesting part.

xaa.dev changes that.

We pre-configured all the components so you can focus on understanding the flow, not debugging dev environments. Go from zero to a working XAA token exchange in under 60 seconds.

Launch the playground. It’s free and requires no signup.

What you can do on xaa.dev

The playground gives you hands-on access to every role in the Cross App Access flow:

Requesting App

Step into the shoes of an AI agent or client application. Authenticate a user, request an ID-JAG from the IdP, and exchange it for an access token at the resource server.

Resource App

See the other side of the transaction. Watch how a resource server validates the identity assertion, verifies the trust relationship, and issues scoped access tokens.

Identity Provider

We’ve built a simulated IdP with pre-configured test users. Log in, see how ID-JAGs are minted, and inspect the cryptographic claims that make XAA secure.

Resource MCP Server

Connect your AI agents using the Model Context Protocol (MCP). The playground provides a ready-to-use MCP server that acts as a resource application, letting you test how AI agents can securely access protected resources through the Cross App Access flow.

Bring your own Requesting App

The built-in Requesting App is great for learning, but the real power comes when you test with your own application, whether it’s a traditional app or an MCP client. Register a client on the playground, grab the configuration, and integrate it into your local app. This lets you validate your XAA implementation against a working IdP and Resource App without spinning up your own infrastructure. The playground documentation walks you through the setup step-by-step.

How to get started

Getting started with xaa.dev takes less than a minute:

Step 1: Open the playground

Visit xaa.dev. No account required.

Step 2: Explore the components

The playground has three components (Requesting App, Resource App, and Identity Provider), each with its own URL. Visit any component to see its configuration and understand how it participates in the XAA flow.

Step 3: Follow the guided flow

Walk through the four steps of the XAA flow: User Authentication (SSO), Token Exchange, Access Token Request, and Access Resource. Inspect the requests and responses at each step to see exactly how XAA works under the hood.

That’s it. No local tools installations, Docker containers, environment variables, or CORS headaches.

Watch this walkthrough video of the playground if you’d like a guided tour:

Why we built a testing site for cross app access

XAA is built on an emerging IETF specification, the Identity Assertion JWT Authorization Grant. As enterprise AI adoption accelerates, there’s a clear need: developers want to understand XAA, but the barrier to entry is too high.

xaa.dev lowers the barrier. It helps you:

  • Learn faster – See the protocol in action before writing any code
  • Build confidently – Understand exactly what tokens to expect and validate
  • Experiment safely – Test edge cases without affecting production systems
Inspect the XAA flow

XAA is how enterprise applications will securely connect in an AI-first world. Whether you’re building agents, integrating SaaS tools, or just curious about modern OAuth patterns, xaa.dev gives you a risk-free environment to learn. Check it out and let us know how it works for you!

Learn more

Ready to go deeper? Check out these resources:

Have questions or feedback? Reach out to us on Twitter, join the conversation on the Okta Developer Forums, or drop a comment below. We’re actively improving xaa.dev based on developer input – your feedback shapes what we build next.

Follow us on Twitter and subscribe to our YouTube channel for more content on identity, security, and building with Okta.

https://developer.okta.com/blog/2026/01/20/xaa-dev-playground
Okta Developer Connect Recap

Identity has become one of the most important control points in modern systems. As applications grow more distributed and AI-driven automation becomes part of everyday workflows, identity increasingly defines how secure, predictable, and trustworthy those systems are. Decisions about access, scope, and lifecycle now shape not only the user experience, but also how well security holds up as systems scale.

With this shift in mind, we hosted our first flagship Okta Developer Connect event in India, in Bengaluru. Led by the Okta Developer Advocacy team, the event brought together developers, architects, engineering managers, IAM practitioners, and technology leaders for a day of focused conversations on how identity needs to evolve as systems scale, and as AI agents begin to act alongside humans.

Where identity, security, and AI connect

Throughout the day, the theme stood out clearly: identity now sits at the intersection of application architecture, security decisions, and system behavior. As organizations integrate across ecosystems and introduce AI-driven workflows, identity increasingly determines how safely systems interact and how confidently access can be controlled.

Identity was discussed as foundational infrastructure, spanning users, applications, APIs, services, and AI agents. The conversations focused on how identity decisions ripple across systems as they become more interconnected, and why those decisions matter long after the first sign-in.

The sessions balanced identity fundamentals with how teams are applying them today. Core identity standards and practices were discussed as the foundation for building secure, interoperable systems that scale. From there, the conversations expanded into modern use cases across Okta’s platform, including identity for AI-driven systems using the Okta MCP Server, Cross App Access, secure integrations through the Okta Integration Network, automation with Okta Workflows, lifecycle management, and emerging capabilities such as Verifiable Digital Credentials.

speakers

A shared conversation with the community

Beyond the talks, Okta Developer Connect was intentionally designed to be interactive, with open discussions, audience Q&A, quizzes, interactive sessions, surveys, and a research panel created space for participation beyond listening. Attendees engaged directly with Okta engineers, product leaders, and advocates, exchanging perspectives shaped by the systems they build and operate. Those exchanges added important context to the day, grounding the conversations in real systems and practical implementations.

community

Looking ahead

Okta Developer Connect Bengaluru marked the beginning of an ongoing series focused on practical identity education and open technical dialogue. The series aims to help teams think more clearly about building enterprise-ready systems, how identity standards and practices are reflected across the stack, and how AI-driven systems impact access and security assumptions.

As identity continues to sit at the intersection of security, system architecture, and AI-driven automation, these conversations will only become more important.

We’re excited to continue building this forum with the developer community, grounded in standards, informed by real-world systems, and focused on designing identity that scales with what comes next.

Stay tuned for upcoming Okta Developer Connect events, and follow OktaDev on LinkedIn and Twitter for the latest updates.

https://developer.okta.com/blog/2026/01/19/okta-developer-connect-recap
Unlock the Secrets of a Custom Sign-In Page with Tailwind and JavaScript

We recommend redirecting users to authenticate via the Okta-hosted sign-in page powered by the Okta Identity Engine (OIE) for your custom-built applications. It’s the most secure method for authenticating. You don’t have to manage credentials in your code and can take advantage of the strongest authentication factors without requiring any code changes.

The Okta Sign-In Widget (SIW) built into the sign-in page does the heavy lifting of supporting the authentication factors required by your organization. Did I mention policy changes won’t need any code changes?

But you may think the sign-in page and the SIW are a little bland. And maybe too Okta for your needs? What if you can have a page like this?

A customized Okta-hosted Sign-In Widget with custom elements, colors, and styles

With a bright and colorful responsive design change befitting a modern lifestyle.

A customized Okta-hosted Sign-In Widget with custom elements, colors, and styles for smaller form factors

Let’s add some color, life, and customization to the sign-in page.

In this tutorial, we will customize the sign-in page for a fictional to-do app. We’ll make the following changes:

  • Use Tailwind CSS framework to create a responsive sign-in page layout
  • Add a footer for custom brand links
  • Display a terms and conditions modal using Alpine.js that the user must accept before authenticating

Take a moment to read this post on customizing the Sign-In Widget if you aren’t familiar with the process, as we will be expanding from customizing the widget to enhancing the entire sign-in page experience.

Stretch Your Imagination and Build a Delightful Sign-In Experience

Customize your Gen3 Okta Sign-In Widget to match your brand. Learn to use design tokens, CSS, and JavaScript for a seamless user experience.

In the post, we covered how to style the Gen3 SIW using design tokens and customize the widget elements using the afterTransform() method. You’ll want to combine elements of both posts for the most customized experience.

Table of Contents

Prerequisites

To follow this tutorial, you need:

  • An Okta account with the Identity Engine, such as the Integrator Free account.
  • Your own domain name
  • A basic understanding of HTML, CSS, and JavaScript
  • A brand design in mind. Feel free to tap into your creativity!
  • An understanding of customizing the sign-in page by following the previous blog post

Let’s get started!

Before we begin, you must configure your Okta org to use your custom domain. Custom domains enable code customizations, allowing us to style more than just the default logo, background, favicon, and two colors. Sign in as an admin and open the Okta Admin Console, navigate to Customizations > Brands and select Create Brand +.

Follow the Customize domain and email developer docs to set up your custom domain on the new brand.

Customize your Okta-hosted sign-in page

We’ll first apply the base configuration using the built-in configuration options in the UI. Add your favorite primary and secondary colors, then upload your favorite logo, favicon, and background image for the page. Select Save when done. Everyone has a favorite favicon, right?

I’ll use #ea3eda and #ffa738 as the primary and secondary colors, respectively.

On to the code. In the Theme tab:

  1. Select Sign-in Page in the dropdown menu
  2. Select the Customize button
  3. On the Page Design tab, select the Code editor toggle to see a HTML page

Note

You can only enable the code editor if you configure a custom domain.

You’ll see the lightweight IDE already has code scaffolded. Press Edit and replace the existing code with the following.

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>

<head>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta name="robots" content="noindex,nofollow" />
  <!-- Styles generated from theme -->
  <link href="{{themedStylesUrl}}" rel="stylesheet" type="text/css">
  <!-- Favicon from theme -->
  <link rel="shortcut icon" href="{{faviconUrl}}" type="image/x-icon">
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link
      href="https://fonts.googleapis.com/css2?family=Inter+Tight:ital,wght@0,100..900;1,100..900&family=Manrope:wght@200..800&display=swap"
      rel="stylesheet">
  <title>{{pageTitle}}</title>
  {{{SignInWidgetResources}}}

  <style nonce="{{nonceValue}}">
    :root {
      --font-header: 'Inter Tight', sans-serif;
      --font-body: 'Manrope', sans-serif;
      --color-gray: #4f4f4f;
      --color-fuchsia: #ea3eda;
      --color-orange: #ffa738;
      --color-azul: #016fb9;
      --color-cherry: #ea3e84;
      --color-purple: #b13fff;
      --color-black: #191919;
      --color-white: #fefefe;
      --color-bright-white: #fff;
      --border-radius: 4px;
      --color-gradient: linear-gradient(12deg, var(--color-fuchsia) 0%, var(--color-orange) 100%);
    }

    {{#useSiwGen3}}
      html {
        font-size: 87.5%;
      }
    {{/useSiwGen3}}

    #okta-auth-container {
      display: flex;
      background-image: {{bgImageUrl}};
    }

    #okta-login-container {
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      width: 50vw;
      background: var(--color-white);
    }
  </style>
</head>

<body>  
  <div id="okta-auth-container">
    <div id="okta-login-container"></div>      
  </div>
    
  <!--
   "OktaUtil" defines a global OktaUtil object
   that contains methods used to complete the Okta login flow.
  -->
  {{{OktaUtil}}}

  <script type="text/javascript" nonce="{{nonceValue}}">
    // "config" object contains default widget configuration
    // with any custom overrides defined in your admin settings.

    const config = OktaUtil.getSignInWidgetConfig();
    config.theme = {
      tokens: {
        BorderColorDisplay: 'var(--color-bright-white)',
        PalettePrimaryMain: 'var(--color-fuchsia)',
        PalettePrimaryDark: 'var(--color-purple)',
        PalettePrimaryDarker: 'var(--color-purple)',
        BorderRadiusTight: 'var(--border-radius)',
        BorderRadiusMain: 'var(--border-radius)',
        PalettePrimaryDark: 'var(--color-orange)',
        FocusOutlineColorPrimary: 'var(--color-azul)',
        TypographyFamilyBody: 'var(--font-body)',
        TypographyFamilyHeading: 'var(--font-header)',
        TypographyFamilyButton: 'var(--font-header)',
        BorderColorDangerControl: 'var(--color-cherry)'
      }
    }

    config.i18n = {
      'en': {
        'primaryauth.title': 'Log in to create tasks',
      }
    }

    // Render the Okta Sign-In Widget
    const oktaSignIn = new OktaSignIn(config);
    oktaSignIn.renderEl({ el: '#okta-login-container' },
      OktaUtil.completeLogin,
      function (error) {
        // Logs errors that occur when configuring the widget.
        // Remove or replace this with your own custom error handler.
        console.log(error.message, error);
      }
    );
  </script>
</body>
</html>

This code adds style configuration to the SIW elements and configures the text for the title when signing in. Press Save to draft.

We must allow Okta to load font resources from an external source, Google, by adding the domains to the allowlist in the Content Security Policy (CSP).

Navigate to the Settings tab for your brand’s Sign-in page. Find the Content Security Policy and press Edit. Add the domains for external resources. In our example, we only load resources from Google Fonts, so we added the following two domains:

*.googleapis.com
*.gstatic.com

Select Save to draft, then Publish to view your changes.

The sign-in page looks more stylized than before. If you try resizing the browser window, we see it’s not handling different form factors well. Let’s use Tailwind CSS to add a responsive layout.

Use Tailwind CSS to build a responsive layout

Tailwind makes delivering cool-looking websites much faster than writing our CSS manually. We’ll load Tailwind via CDN for our demonstration purposes.

Add the CDN to your CSP allowlist:

https://cdn.jsdelivr.net

Navigate to Page Design, then Edit the page. Add the script to load the Tailwind resources in the <head>. I added it after the <style></style> definitions before the </head>.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4" nonce="{{nonceValue}}"></script>

Loading external resources, like styles and scripts, requires a CSP nonce to mitigate cross-site scripting (XSS). You can read more about the CSP nonce on the CSP Quick Reference Guide.

Note

Don’t use Tailwind from NPM CDN for production use cases. The Tailwind documentation notes this is for experimentation and prototyping only, as the CDN has rate limits. If your brand uses Tailwind for other production sites, you’ve most likely defined custom mixins and themes in Tailwind. Therefore, reference your production Tailwind resources in place of the CDN we’re using in this post.

Remove the styles for #okta-auth-container and #okta-login-container from the <style></style> section. We can use Tailwind to handle it. The <style></style> section should only contain the CSS custom properties defined in :root and the directive to use SIW Gen3.

Add the styles for Tailwind. We’ll add the classes to show the login container without the hero image in smaller form factors, then display the hero image with different widths depending on the breakpoints.

The two div containers look like this:

<div id="okta-auth-container" class="h-screen flex bg-(--color-gray) bg-[{{bgImageUrl}}]">
  <div id="okta-login-container" class="w-full min-w-sm lg:w-2/3 xl:w-1/2 bg-(image:--color-gradient) lg:bg-none bg-(--color-white) flex justify-center items-center"></div>
</div>

Save the file and publish the changes. Feel free to test it out!

Use Tailwind for custom HTML elements on your Okta-hosted sign-in page

Tailwind excels at adding styled HTML elements to websites. We can also take advantage of this. Let’s say you want to maintain continuity of the webpage from your site through the sign-in page by adding a footer with links to your brand’s sites. Adding this new section involves changing the HTML node structure and styling the elements.

We want a footer pinned to the bottom of the view, so we’ll need a new parent container with vertical stacking and ensure the height of the footer stays consistent. Replace the HTML node structure to look like this:

<div class="flex flex-col min-h-screen">        
  <div id="okta-auth-container" class="flex grow bg-(--color-gray) bg-[{{bgImageUrl}}]">
    <div class="w-full min-w-sm lg:w-2/3 xl:w-1/2 bg-(image:--color-gradient) lg:bg-none bg-(--color-white) flex justify-center items-center">
        <div id="okta-login-container"></div>
    </div>
  </div>
  <footer class="font-(family-name:--font-body)">
    <ul class="h-12 flex justify-evenly items-center text-(--color-azul)">
      <li><a class="hover:text-(--color-orange) hover:underline" href="https://developer.okta.com">Terms</a></li>
      <li><a class="hover:text-(--color-orange) hover:underline" href="https://developer.okta.com">Docs</a></li>
      <li><a class="hover:text-(--color-orange) hover:underline" href="https://developer.okta.com/blog">Blog</a></li>
      <li><a class="hover:text-(--color-orange) hover:underline" href="https://devforum.okta.com">Community</a></li>
    </ul>
  </footer>
</div>

Everything redirects to the Okta Developer sites. 😊 I also maintained the style of font, text colors, and text decoration styles to match the SIW elements. CSS custom properties make consistency manageable.

Feel free to save and publish to check it out!

Add custom interactivity on the Okta-hosted sign-in page using an external library

Tailwind is great at styling HTML elements, but it’s not a JavaScript library. If we want interactive elements on the sign-in page, we must rely on Web APIs or libraries to assist us. Let’s say we want to ensure that users who sign in to the to-do app agree to the terms and conditions. We want a modal that blocks interaction with the SIW until the user agrees.

We’ll use Alpine for the heavy lifting because it’s a lightweight JavaScript library that suits this need. We add the library via the NPM CDN, as we have already allowed the domain in our CSP. Add the following to the <head></head> section of the HTML. I added mine directly after the Tailwind script.

<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" nonce="{{nonceValue}}"></script>

Note

We’re including Alpine from the NPM CDN for demonstration and experimentation. For production applications, use a CDN that supports production scale. The NPM CDN applies rate limiting to prevent production-grade use.

Next, we add the HTML tags to support the modal. Replace the HTML node structure to look like this:

<div class="flex flex-col min-h-screen">
  <div id="modal"
    x-data
    x-cloak
    x-show="$store.modal.open" 
    x-transition:enter="transition ease-out duration-300"
    x-transition:enter-start="opacity-0"
    x-transition:enter-end="opacity-100"
    x-transition:leave="transition ease-in duration-200"
    x-transition:leave-start="opacity-100"
    x-transition:leave-end="opacity-0 hidden"
    class="fixed inset-0 z-50 flex items-center justify-center bg-(--color-black)/80 bg-opacity-50">
    <div x-transition:enter="transition ease-out duration-300"
         x-transition:enter-start="opacity-0 scale-90"
         x-transition:enter-end="opacity-100 scale-100"
         x-transition:leave="transition ease-in duration-200"
         x-transition:leave-start="opacity-100 scale-100"
         x-transition:leave-end="opacity-0 scale-90"
         class="bg-(--color-white) rounded-(--border-radius) shadow-lg p-8 max-w-md w-full mx-4">
      <h2 class="text-2xl font-(family-name:--font-header) text-(--color-black) mb-4 text-center">Welcome to to-do app</h2>
      <p class="text-(--color-black) mb-6">This app is in beta. Thank you for agreeing to our terms and conditions.</p>
      <button @click="$store.modal.hide()" 
              class="w-full bg-(--color-fuchsia) hover:bg-(--color-orange) text-(--color-bright-white) font-medium py-2 px-4 rounded-(--border-radius) transition duration-200">
          Agree
      </button>
    </div>
  </div>        
  <div id="okta-auth-container" class="flex grow bg-(--color-gray) bg-[{{bgImageUrl}}]">
    <div class="w-full min-w-sm lg:w-2/3 xl:w-1/2 bg-(image:--color-gradient) lg:bg-none bg-(--color-white) flex justify-center items-center">
      <div id="okta-login-container"></div>
    </div>
  </div>
  <footer class="font-(family-name:--font-body)">
    <ul class="h-12 flex justify-evenly items-center text-(--color-azul)">
      <li><a class="hover:text-(--color-orange) hover:underline" href="https://developer.okta.com">Terms</a></li>
      <li><a class="hover:text-(--color-orange) hover:underline" href="https://developer.okta.com">Docs</a></li>
      <li><a class="hover:text-(--color-orange) hover:underline" href="https://developer.okta.com/blog">Blog</a></li>
      <li><a class="hover:text-(--color-orange) hover:underline" href="https://devforum.okta.com">Community</a></li>
    </ul>
  </footer>
</div>

It’s a lot to add, but I want the smooth transition animations. 😅 The built-in enter and leave states make adding the transition animation so much easier than doing it manually.

Notice we’re using a state value to determine whether to show the modal. We’re using global state management, and setting it up is the next step. We’ll add initializing the state when Alpine initializes. Find the comment // Render the Okta Sign-In Widget within the <script></script> section, and add the following code that runs after Alpine initializes:

document.addEventListener('alpine:init', () => {
  Alpine.store('modal', {
    open: true,
    show() {
      this.open = true;
    },
    hide() {
      this.open = false;
    }
  });
});

The event listener watches for the alpine:init event and runs a function that defines an element in Alpine’s store, modal. The modal store contains a property to track whether it’s open and some helper methods for showing and hiding.

When you save and publish, you’ll see the modal upon site reload!

A modal which displays on top of the sign-in page where the user must accept terms before continuing

We made the modal fixed even if the user presses Esc or selects the scrim. Users must agree to the terms to continue.

Customize Okta-hosted sign-in page behavior using Web APIs

We display the modal as soon as the webpage loads. It works, but we can also display the modal after the Sign-In Widget renders. Doing so allows us to use the nice enter and leave CSS transitions Alpine supports. We want to watch for changes to the DOM within the <div id="okta-login-container"></div>. This is the parent container that renders the SIW. We can use the MutationObserver Web API and watch for DOM mutations within the div.

In the <script></script> section, after the event listener for alpine:init, add the following code:

const loginContainer = document.querySelector("#okta-login-container");

// Use MutationObserver to watch for auth container element
const mutationObserver = new MutationObserver(() => {
  const element = loginContainer.querySelector('[data-se*="auth-container"]');
  if (element) {
    document.getElementById('modal').classList.remove('hidden');
    // Open modal using Alpine store
    Alpine.store('modal').show();
    // Clean up the observer
    mutationObserver.disconnect();
  }
});

mutationObserver.observe(loginContainer, {
  childList: true,
  subtree: true
});

Let’s walk through what the code does. First, we’re creating a variable to reference the parent container for the SIW, as we’ll use it as the root element to target our work. Mutation observers can negatively impact performance, so it’s essential to limit the scope of the observer as much as possible.

Create the observer

We create the observer and define the behavior for observation. The observer first looks for the element with the data attribute named se, which includes the value auth-container. Okta adds a node with the data attribute for internal operations. We’ll do the same for our internal operations. 😎

Define the behavior upon observation

Once we have an element matching the auth-container data attribute, we show the modal, which triggers the enter transition animation. Then we clean up the observer.

Identify what to observe

We begin by observing the DOM and pass in the element to use as the root, along with a configuration specifying what to watch for. We want to look for changes in child elements and the subtree from the root to find the SIW elements.

Lastly, let’s enable the modal to trigger based on the observer. I intentionally provided you with code snippets that force the modal to display before the SIW renders, so you could take sneak peeks at your work as we went along.

In the HTML node structure, find the <div id="modal">. It’s missing a class that hides the modal initially. Add the class hidden to the class list. The class list for the <div> should look like

<div id="modal"
    x-data
    x-cloak
    x-show="$store.modal.open" 
    x-transition:enter="transition ease-out duration-300"
    x-transition:enter-start="opacity-0"
    x-transition:enter-end="opacity-100"
    x-transition:leave="transition ease-in duration-200"
    x-transition:leave-start="opacity-100"
    x-transition:leave-end="opacity-0 hidden"
    class="hidden fixed inset-0 z-50 flex items-center justify-center bg-(--color-black)/80 bg-opacity-50">

<!-- Remaining modal structure here. Compare your work to the class list above -->

</div>

Then, in the alpine:init event listener, change the modal’s open property to default to false:

document.addEventListener('alpine:init', () => {
  Alpine.store('modal', {
    open: false,
    show() {
      this.open = true;
    },
    hide() {
      this.open = false;
    }
  });
});

Save and publish your changes. You’ll now notice a slight delay before the modal eases into view. So smooth!

A customized Okta-hosted Sign-In Widget with custom elements, colors, and styles

It’s worth noting that our solution isn’t foolproof; a savvy user can hide the modal and continue interacting with the sign-in widget by manipulating elements in the browser’s debugger. You’ll need to add extra checks and more robust code for foolproof methods. Still, this example provides a general idea of capabilities and how one might approach adding interactive components to the sign-in experience.

Don’t forget to test any implementation changes to the sign-in page for accessibility. The default site and the sign-in widget are accessible. Any changes or customizations we make may alter the accessibility of the site.

You can connect your brand to one of our sample apps to see it work end-to-end. Follow the instructions in the README of our Okta React Sample to run the app locally. You’ll need to update your Okta OpenID Connect (OIDC) application to work with the domain. In the Okta Admin Console, navigate to Applications > Applications and find the Okta application for your custom app. Navigate to the Sign On tab. You’ll see a section for OpenID Connect ID Token. Select Edit and select Custom URL for your brand’s sign-in URL as the Issuer value.

You’ll use the issuer value, which matches your brand’s custom URL, and the Okta application’s client ID in your custom app’s OIDC configuration.

Add Tailwind, Web APIs, and JavaScript libraries to customize your Okta-hosted sign-in page

I hope you found this post interesting and unlocked the potential of how much you can customize the Okta-hosted Sign-In Widget experience.

You can find the final code for this project in the GitHub repo.

If you liked this post, check out these resources.

Remember to follow us on LinkedIn and subscribe to our YouTube for more exciting content. Let us know how you customized the Okta-hosted sign-in page. We’d love to see what you came up with.

We also want to hear from you about topics you want to see and questions you may have. Leave us a comment below!

https://developer.okta.com/blog/2025/11/24/okta-custom-sign-in-page
Secure Authentication with a Push Notification in Your iOS Device

Building secure and seamless sign-in experiences is a core challenge for today’s iOS developers. Users expect authentication that feels instant, yet protects them with strong safeguards like multi-factor authentication (MFA). With Okta’s DirectAuth and push notification support, you can achieve both – delivering native, phishing-resistant MFA flows without ever leaving your app.

In this post, we’ll walk you through how to:

  1. Set up your Okta developer account
  2. Configure your Okta org for DirectAuth and push notification factor
  3. Enable your iOS app to drive DirectAuth flows natively
  4. Create an AuthService with the support of DirectAuth
  5. Build a fully working SwiftUI demo leveraging the AuthService

Note: This guide assumes you’re comfortable developing in Xcode using Swift and have basic familiarity with Okta’s identity flows.

If you want to skip the tutorial and run the project, you can follow the instructions in the project’s README.

Table of Contents

Use Okta DirectAuth with push notification factor

The first step in implementing Direct Authentication with push-based MFA is setting up your Okta org and enabling the Push Notification factor. DirectAuth allows your app to handle authentication entirely within its own native UI – no browser redirection required – while still leveraging Okta’s secure OAuth 2.0 and OpenID Connect (OIDC) standards under the hood.

This means your app can seamlessly verify credentials, obtain tokens, and trigger a push notification challenge without switching contexts or relying on the SafariViewController.

Before you begin, you’ll need an Okta Integrator Free Plan account. To get one, sign up for an Integrator account. Once you have an account, sign in to your Integrator account. Next, in the Admin Console:

  1. Go to Applications > Applications
  2. Select Create App Integration
  3. Select OIDC - OpenID Connect as the sign-in method
  4. Select Native Application as the application type, then select Next
  5. Enter an app integration name
  6. Configure the redirect URIs:
    • Redirect URI: com.okta.{yourOktaDomain}:/callback
    • Post Logout Redirect URI: com.okta.{yourOktaDomain}:/ (where {yourOktaDomain}.okta.com is your Okta domain name). Your domain name is reversed to provide a unique scheme to open your app on a device.
  7. Select Advanced v.
    • Select the OOB and MFA OOB grant types.
  8. In the Controlled access section, select the appropriate access level
  9. Select Save

NOTE: When using a custom authorization server, you need to set up authorization policies. Complete these additional steps:

  1. In the Admin Console, go to Security > API > Authorization Servers
  2. Select your custom authorization server (default)
  3. On the Access Policies tab, ensure you have at least one policy:
    • If no policies exist, select Add New Access Policy
    • Give it a name like “Default Policy”
    • Set Assign to “All clients”
    • Click Create Policy
  4. For your policy, ensure you have at least one rule:
    • Select Add Rule if no rules exist
    • Give it a name like “Default Rule”
    • Set Grant type is to “Authorization Code”
    • Select Advanced and enable “MFA OOB”
    • Set User is to “Any user assigned the app”
    • Set Scopes requested to “Any scopes”
    • Select Create Rule

For more details, see the Custom Authorization Server documentation.

Where are my new app's credentials?

Creating an OIDC Native App manually in the Admin Console configures your Okta Org with the application settings.

After creating the app, you can find the configuration details on the app’s General tab:

  • Client ID: Found in the Client Credentials section
  • Issuer: Found in the Issuer URI field for the authorization server that appears by selecting Security > API from the navigation pane.
  Issuer:    https://dev-133337.okta.com/oauth2/default
  Client ID: 0oab8eb55Kb9jdMIr5d6

NOTE: You can also use the Okta CLI Client or Okta PowerShell Module to automate this process. See this guide for more information about setting up your app.

Prefer phishing-resistant authentication factors

When implementing DirectAuth with push notifications, security remains your top priority. Every new Okta Integrator Free Plan account requires admins to configure multi-factor authentication (MFA) using Okta Verify by default. We’ll keep these default settings for this tutorial, as they already support Okta Verify Push, the recommended factor for a native and secure authentication experience.

Push notifications through Okta Verify provide strong, phishing-resistant protection by requiring the user to approve sign-in attempts directly from a trusted device. Combined with biometric verification (Face ID or Touch ID) or device PIN enforcement, Okta Verify Push ensures that only the legitimate user can complete the authentication flow – even if credentials are compromised.

By default, push factor isn’t enabled in the Integrator Free org. Let’s enable it now.

Navigate to Security > Authenticators. Find Okta Verify and select Actions > Edit. In the Okta Verify modal, find Verification options and select Push notification (Android and iOS only). Select Save.

Set up your iOS project with Okta’s mobile SDKs

Before integrating Okta DirectAuth and Push Notification MFA, make sure your development environment meets the following requirements:

  • Xcode 15.0 or later – This guide assumes you’re comfortable developing iOS apps in Swift using Xcode.
  • Swift 5+ – All examples use modern Swift language features.
  • Swift Package Manager (SPM) – Dependency manager handled through SPM, which is built into Xcode.

Once your environment is ready, create a new iOS project in Xcode and prepare it for integration with Okta’s mobile libraries.

Authenticate your iOS app using Okta DirectAuth

If you are starting from scratch, create a new iOS app:

  1. Open Xcode
  2. Go to File > New > Project
  3. Select iOS App and select Next
  4. Enter the name of the project, such as “okta-mfa-direct-auth”
  5. Set the Interface to SwiftUI
  6. Select Next and save your project locally

To integrate Okta’s Direct Authentication SDK into your iOS app, we’ll use Swift Package Manager (SPM) – the recommended and modern way to manage dependencies in Xcode.

Follow these steps:

  1. Open your project in Xcode (or create a new one if needed)
  2. Go to File > Add Package Dependencies
  3. In the search field at the top-right, enter: https://github.com/okta/okta-mobile-swift and press Return. Xcode will automatically fetch the available packages.
  4. Select the latest version (recommended) or specify a compatible version with your setup
  5. When prompted to choose which products to add, ensure that you select your app target next to OktaDirectAuth and AuthFoundation
  6. Select Add Package

These packages provide all the tools you need to implement native authentication flows using OAuth 2.0 and OpenID Connect (OIDC) with DirectAuth, including secure token handling and MFA challenge management – without relying on a browser session.

Once the integration is complete, you’ll see OktaMobileSwift and its dependencies listed under your project’s Package Dependencies section in Xcode.

Add the OIDC configuration to your iOS app

The cleanest and most scalable way to manage configuration is to use a property list file for Okta stored in your app bundle.

Create the property list for your OIDC and app config by following these steps:

  1. Right-click on the root folder of the project
  2. Select New File from Template (New File in legacy Xcode versions)
  3. Ensure you have iOS selected on the top picker
  4. Select Property List template and select Next
  5. Name the template Okta and select Create to create an Okta.plist file

You can edit the file in XML format by right-clicking and selecting Open As > Source Code. Copy and paste the following code into the file.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>scopes</key>
    <string>openid profile offline_access</string>
    <key>redirectUri</key>
    <string>com.okta.{yourOktaDomain}:/callback</string>
    <key>clientId</key>
    <string>{yourClientID}</string>
    <key>issuer</key>
    <string>{yourOktaDomain}/oauth2/default</string>
    <key>logoutRedirectUri</key>
    <string>com.okta.{yourOktaDomain}:/</string>
</dict>
</plist>

Replace {yourOktaDomain} and {yourClientID} with the values from your Okta org.

If you use something like this in your code, you can directly access the DirectAuth shared instance, which is already initialized and ready to handle authentication requests.

Add authentication in your iOS app without a browser redirect using Okta DirectAuth

Now that you’ve added the SDK and property list file, let’s implement the main authentication logic for your app.

We’ll build a dedicated service called AuthService, responsible for logging users in and out, refreshing tokens, and managing session state.

This service will rely on OktaDirectAuth for native authentication and AuthFoundation for secure token handling.

To set it up, create a new folder named Auth under your project’s folder structure, then add a new Swift file called AuthService.swift.

Here, you’ll define your authentication protocol and a concrete class that integrates directly with the Okta SDK – making it easy to use across your SwiftUI or UIKit views.

import AuthFoundation
import OktaDirectAuth
import Observation
import Foundation

protocol AuthServicing {
  // The accessToken of the logged in user
  var accessToken: String? { get }

  // State for driving SwiftUI
  var state: AuthService.State { get }

  // Sign in (Password + Okta Verify Push)
  func signIn(username: String, password: String) async throws

  // Sign out & revoke tokens
  func signOut() async

  // Refresh access token if possible (returns updated token if refreshed)
  func refreshTokenIfNeeded() async throws

  // Getting the userInfo out of the Credential
  func userInfo() async throws -> UserInfo?
}

With this added, you will get an error that AuthService can’t be found. That’s because we haven’t created the class yet. Below this code, add the following declarations of the AuthService class:

@Observable
final class AuthService: AuthServicing {

}

After doing so, we next need to confirm the AuthService class to the AuthServicing protocol and also create the State enum, which will hold all the states of our Authentication process.

To do that, first let’s create the State enum inside the AuthService class like this:

@Observable
final class AuthService: AuthServicing {
  enum State: Equatable {
    case idle
    case authenticating
    case waitingForPush
    case authorized(Token)
    case failed(errorMessage: String)
  }
}

The new code resolved the two errors about the AuthService and the State enum. We only have one error to fix, which is confirming the class to the protocol.

We will start implementing the functions top to bottom. Let’s first add the two variables from the protocol, accessToken and state. After the definition of the enum, we will add the properties:

@Observable
final class AuthService: AuthServicing {
  enum State: Equatable {
    case idle
    case authenticating
    case waitingForPush
    case authorized(Token)
    case failed(errorMessage: String)
  }

  private(set) var state: State = .idle

  var accessToken: String? {
    return nil
  }
}

For now, we will leave the accessToken getter with a return value of nil, as we are not using the token yet. We’ll add the implementation later.

Next, we’ll add a private property to hold a reference to the DirectAuthenticationFlow instance.

This object manages the entire DirectAuth process, including credential verification, MFA challenges, and token issuance. The object must persist across authentication steps.

Insert the following variable between the existing state and accessToken properties:

private(set) var state: State = .idle
@ObservationIgnored private let flow: DirectAuthenticationFlow?

var accessToken: String? {
  return nil
}

To allocate the flow variable, we will need to implement an initializer for the AuthService class. Inside, we’ll allocate the flow using the PropertyListConfiguration that we introduced earlier. Just after the accessToken getter, add the following function:

// MARK: Init

init() {
  // Prefer PropertyListConfiguration if Okta.plist exists; otherwise fall back
  if let configuration = try? OAuth2Client.PropertyListConfiguration() {
      self.flow = try? DirectAuthenticationFlow(client: OAuth2Client(configuration))
  } else {
      self.flow = try? DirectAuthenticationFlow()
  }
}

This will try to fetch the Okta.plist file from the project’s folder, and if not found, will fall back to the default initializer of the DirectAuthenticationFlow. We have now successfully allocated the DirectAuthenticationFlow, and we can proceed with implementing the next functions of the protocol.

Moving down to the first function in the protocol, which is the signIn(username: String, password: String).

The signIn method below performs the full authentication flow using Okta DirectAuth and Auth Foundation. It authenticates a user with their username and password, handles MFA challenges (in this case, Okta Verify Push), and securely stores the resulting token for future API calls. Add the following code just under the Init that we just added.

// MARK: AuthServicing
func signIn(username: String, password: String) {
  Task { @MainActor in
    // 1️⃣ Start the Sign-In Process
    // Update UI state and begin the DirectAuth flow with username/password.
    state = .authenticating
    do {
      let result = try await flow?.start(username, with: .password(password))

      switch result {
        // 2️⃣ Handle Successful Authentication
        // Okta validated credentials, return access/refresh/ID tokens.
      case .success(let token):
        let newCred = try Credential.store(token)
        Credential.default = newCred
        state = .authorized(token)

        // 3️⃣ Handle MFA with Push Notification
        // Okta requires MFA, wait for push approval via Okta Verify.
      case .mfaRequired:
        state = .waitingForPush
        let status = try await flow?.resume(with: .oob(channel: .push))
        if case let .success(token) = status {
          Credential.default = try Credential.store(token)
          state = .authorized(token)
        }
      default:
        break
      }
    } catch {
      // 4️⃣ Handle Errors Gracefully
      // Update state with a descriptive error message for the UI.
      state = .failed(errorMessage: error.localizedDescription)
    }
  }
}

Let’s break down what’s happening step by step:

1. Start the sign-in process

When the function is called, it launches a new asynchronous Task and sets the UI state to .authenticating. It then initiates the DirectAuth flow using the provided username and password:

let result = try await flow?.start(username, with: .password(password))

This sends the user’s credentials to Okta’s Direct Authentication API and waits for a response.

2. Handle successful authentication

If Okta validates the credentials and no additional verification is needed, the result will be .success(token).

The returned Token object contains access, refresh, and ID tokens.

We securely persist the credentials using AuthFoundation:

let newCred = try Credential.store(token)
Credential.default = newCred
state = .authorized(token)

This marks the user as authenticated and updates the app state, allowing your UI to transition to the signed-in experience.

3. Handle MFA with push notification

If Okta determines that an MFA challenge is required, the result will be .mfaRequired. The app updates its state to .waitingForPush, prompting the user to approve the login on their Okta Verify app:

state = .waitingForPush
let status = try await flow?.resume(with: .oob(channel: .push))

The .oob(channel: .push) parameter resumes the authentication flow by waiting for the push approval event from Okta Verify.

Once the user approves, Okta returns a new token:

if case let .success(token) = status {
    Credential.default = try Credential.store(token)
    state = .authorized(token)
}

4. Handle errors

If any step fails (e.g., invalid credentials, network issues, or push timeout), the catch block updates the UI to show an error message:

state = .failed(errorMessage: error.localizedDescription)

The error function allows your app to display user-friendly error states while preserving robust error handling for debugging.

Secure, native sign-in in iOS

This function demonstrates a complete native sign-in experience with Okta DirectAuth, no web views, no redirects.

It authenticates the user, manages token storage securely, and handles push-based MFA all within your app’s Swift layer – making the authentication flow fast, secure, and frictionless.

The following diagram illustrates how the authentication flow works under the hood when using Okta DirectAuth with push notification authentication factor:

Flowchart showing the sequence of steps for authentication flow

Sign-out users when using DirectAuth

Next from the protocol functions is the sign-out method. This method provides a clean and secure way to log the user out of the app.

It revokes the user’s active tokens from Okta and resets the local authentication state, ensuring that no stale credentials remain on the device. Add the following code right below the signIn method:

func signOut() async {
  if let credential = Credential.default {
    try? await credential.revoke()
  }
  Credential.default = nil
  state = .idle
}

Let’s look at what each step does: 1. Check for an existing credential

if let credential = Credential.default {

The method first checks if a stored credential (token) exists in memory. Credential.default represents the current authenticated session created earlier during sign-in.

2. Revoke the tokens from Okta

try? await credential.revoke()

This line tells Okta to invalidate the access and refresh tokens associated with that credential. Calling revoke() ensures that the user’s session terminates locally and in the authorization server, preventing further API access with those tokens.

The try? operator is used to safely ignore any errors (e.g., network failure during logout), since token revocation is a best-effort operation.

3. Clear local credential data

Credential.default = nil

After revoking the tokens, the app clears the local credential object.

This removes any sensitive authentication data from memory, ensuring that no valid tokens remain on the device.

4. Reset the authentication state

state = .idle

Finally, the app updates its internal state back to .idle, which tells the UI that the user is now logged out and ready to start a new session.

You can use this state to trigger a transition back to the login screen or turn off authenticated features.

The protocol confirmation is almost complete, and we only have two functions remaining to implement.

Refresh access tokens securely

Access tokens issued by Okta have a limited lifetime to reduce the risk of misuse if compromised. OAuth clients that can’t maintain secrets, like mobile apps, require short access token lifetimes for security.

To maintain a seamless user experience, your app should refresh tokens automatically before they expire. The refreshTokenIfNeeded() method handles this process securely using AuthFoundation’s built-in token management APIs.

Let’s walk through what it does. Add the following code right after the signOut method:

func refreshTokenIfNeeded() async throws {
  guard let credential = Credential.default else { return }
  try await credential.refresh()
}

1. Check for an existing credential

guard let credential = Credential.default else { return }

Before attempting a token refresh, the method checks whether a valid credential exists. If no credential is stored (e.g., the user hasn’t signed in yet or has logged out), the method exits early.

2. Refresh the token

try await credential.refresh()

This line tells Okta to exchange the refresh token for a new access token and ID token.

The refresh() method automatically updates the Credential object with the new tokens and securely persists them using AuthFoundation.

If the refresh token has expired or is invalid, this call throws an error – allowing your app to detect the issue and prompt the user to sign in again.

Display the authenticated user’s information

Lastly, let’s look at the userInfo() function. After authenticating, your app can access the user’s profile information – such as their name, email, or user ID – from Okta using a standard OIDC endpoint.

The userInfo() method retrieves this data from the ID token or by calling the authorization server’s /userinfo endpoint. The ID token doesn’t necessarily include all of the profile information though, as the ID token is intentionally lightweight.

Here’s how it works. Add the following code after the end of refreshTokenIfNeeded():

func userInfo() async throws -> UserInfo? {
  if let userInfo = Credential.default?.userInfo {
    return userInfo
  } else {
    do {
      guard let userInfo = try await Credential.default?.userInfo() else {
        return nil
      }
      return userInfo
    } catch {
      return nil
    }
  }
}

1. Return the cached user info

if let userInfo = Credential.default?.userInfo {
  return userInfo
}

If the user’s profile information has already been fetched and stored in memory, the method returns it immediately.

This avoids unnecessary network calls, providing a fast and responsive experience.

2. Fetch user info

guard let userInfo = try await Credential.default?.userInfo() else {
  return nil
}

If the cached data isn’t available, the method fetches it directly from Okta using the UserInfo endpoint.

This endpoint returns standard OpenID Connect claims such as:

sub (the user's unique ID)
name
email
preferred_username
etc...

The AuthFoundation SDK handles the request and parsing for you, returning a UserInfo object.

3. Handle errors gracefully

catch {
  return nil
}

If the request fails (for example, due to a network issue or expired token), the function returns nil. This prevents your app from crashing and allows you to handle the error by displaying a default user state or prompting re-authentication.

With this implemented, you’ve resolved all the errors and should be able to build the app. 🎉

Build the SwiftUI views to display authenticated state

Now that we’ve built the AuthService to handle sign-in, sign-out, token management, and user info retrieval, let’s see how to integrate it into your app’s UI.

To maintain consistency in your architecture, rename the default ContentView to AuthView and update all references accordingly.

This clarifies the purpose of the view – it will serve as the primary authentication interface. Then, create a Views folder under your project’s folder, drag and drop the AuthView into the newly created folder, and create a new file named AuthViewModel.swift in the same folder.

The AuthViewModel will encapsulate all authentication-related state and actions, acting as the communication layer between your view and the underlying AuthService.

Add the following code in AuthViewModel.swift:

import Foundation
import Observation
import AuthFoundation

/// The `AuthViewModel` acts as the bridge between your app's UI and the authentication layer (`AuthService`).
/// It coordinates user actions such as signing in, signing out, refreshing tokens, and fetching user profile data.
/// This class uses Swift's `@Observable` macro so that your SwiftUI views can automatically react to state changes.
@Observable
final class AuthViewModel {
  // MARK: - Dependencies

  /// The authentication service responsible for handling DirectAuth sign-in,
  /// push-based MFA, token management, and user info retrieval.
  private let authService: AuthServicing

  // MARK: - UI State Properties

  /// Stores the user's token, which can be used for secure communication
  /// with backend services that validate the user's identity.
  var accessToken: String?

  /// Represents a loading statex. Set to `true` when background operations are running
  /// (such as sign-in, sign-out, or token refresh) to display a progress indicator.
  var isLoading: Bool = false

  /// Holds any human-readable error messages that should be displayed in the UI
  /// (for example, invalid credentials or network errors).
  var errorMessage: String?

  /// The username and password properties are bound to text fields in the UI.
  /// As the user types, these values update automatically thanks to SwiftUI's reactive data binding.
  /// The view model then uses them to perform DirectAuth sign-in when the user submits the form.
  var username: String = ""
  var password: String = ""

  /// Exposes the current authentication state (idle, authenticating, waitingForPush, authorized, failed)
  /// as defined by the `AuthService.State` enum. The view can use this to display the correct UI.
  var state: AuthService.State {
    authService.state
  }

  // MARK: - Initialization

  /// Initializes the view model with a default instance of `AuthService`.
  /// You can inject a mock `AuthServicing` implementation for testing.
  init(authService: AuthServicing = AuthService()) {
    self.authService = authService
  }

  // MARK: - Authentication Actions

  /// Attempts to authenticate the user with the provided credentials.
  /// This triggers the full DirectAuth flow -- including password verification,
  /// push notification MFA (if required), and secure token storage via AuthFoundation.
  @MainActor
  func signIn() async {
    setLoading(true)
    defer { setLoading(false) }

    do {
      try await authService.signIn(username: username, password: password)
      accessToken = authService.accessToken
    } catch {
      errorMessage = error.localizedDescription
    }
  }

  /// Signs the user out by revoking active tokens, clearing local credentials,
  /// and resetting the app's authentication state.
  @MainActor
  func signOut() async {
    setLoading(true)
    defer { setLoading(false) }

    await authService.signOut()
  }

  // MARK: - Token Handling

  /// Refreshes the user's access token using their refresh token.
  /// This allows the app to maintain a valid session without requiring
  /// the user to log in again after the access token expires.
  @MainActor
  func refreshToken() async {
    setLoading(true)
    defer { setLoading(false) }

    do {
      try await authService.refreshTokenIfNeeded()
      accessToken = authService.accessToken
    } catch {
      errorMessage = error.localizedDescription
    }
  }

  // MARK: - User Info Retrieval

  /// Fetches the authenticated user's profile information from Okta.
  /// Returns a `UserInfo` object containing standard OIDC claims (such as `name`, `email`, and `sub`).
  /// If fetching fails (e.g., due to expired tokens or network issues), it returns `nil`.
  @MainActor
  func fetchUserInfo() async -> UserInfo? {
    do {
      let userInfo = try await authService.userInfo()
      return userInfo
    } catch {
      errorMessage = error.localizedDescription
      return nil
    }
  }

  // MARK: - UI Helpers

  /// Updates the `isLoading` property. This is used to show or hide
  /// a loading spinner in your SwiftUI view while background work is in progress.
  private func setLoading(_ value: Bool) {
    isLoading = value
  }
}

With the view model in place, the next step is to bind it to your SwiftUI view. The AuthView will observe the AuthViewModel, updating automatically as the authentication state changes.

It will show the user’s ID token when authenticated and provide controls for signing in, signing out, and refreshing the token.

Open AuthView.swift, remove the existing template code, and insert the following implementation:

import SwiftUI
import AuthFoundation

/// A simple wrapper for `UserInfo` used to present user profile data in a full-screen modal.
/// Conforms to `Identifiable` so it can be used with `.fullScreenCover(item:)`.
struct UserInfoModel: Identifiable {
  let id = UUID()
  let user: UserInfo
}

/// The main SwiftUI view for managing the authentication experience.
/// This view observes the `AuthViewModel`, displays different UI states
/// based on the current authentication flow, and provides controls for
/// signing in, signing out, refreshing tokens, and viewing user or token information.
struct AuthView: View {

  // MARK: - View Model

  /// The view model that manages all authentication logic and state transitions.
  /// It uses `@Observable` from Swift's Observation framework, so changes here
  /// automatically trigger UI updates.
  @State private var viewModel = AuthViewModel()

  // MARK: - State and Presentation

  /// Holds the currently fetched user information (if available).
  /// When this value is set, the `UserInfoView` is displayed as a full-screen sheet.
  @State private var userInfo: UserInfoModel?

  /// Controls whether the Token Info screen is presented as a full-screen modal.
  @State private var showTokenInfo = false

  // MARK: - View Body

  var body: some View {
    VStack {
      // Render the UI based on the current authentication state.
      // Each case corresponds to a different phase of the DirectAuth flow.
      switch viewModel.state {
      case .idle, .failed:
        loginForm
      case .authenticating:
        ProgressView("Signing in...")
      case .waitingForPush:
        // Waiting for Okta Verify push approval
        WaitingForPushView {
          Task { await viewModel.signOut() }
        }
      case .authorized:
        successView
      }
    }
    .padding()
  }
}

// MARK: - Login Form View
private extension AuthView {
  /// The initial sign-in form displayed when the user is not authenticated.
  /// Captures username and password input and triggers the DirectAuth sign-in flow.
  private var loginForm: some View {
    VStack(spacing: 16) {
      Text("Okta DirectAuth (Password + Okta Verify Push)")
        .font(.headline)

      // Email input field (bound to view model's username property)
      TextField("Email", text: $viewModel.username)
        .keyboardType(.emailAddress)
        .textContentType(.username)
        .textInputAutocapitalization(.never)
        .autocorrectionDisabled()

      // Secure password input field
      SecureField("Password", text: $viewModel.password)
        .textContentType(.password)

      // Triggers authentication via DirectAuth and Push MFA
      Button("Sign In") {
        Task { await viewModel.signIn() }
      }
      .buttonStyle(.borderedProminent)
      .disabled(viewModel.username.isEmpty || viewModel.password.isEmpty)

      // Display error message if sign-in fails
      if case .failed(let message) = viewModel.state {
        Text(message)
          .foregroundColor(.red)
          .font(.footnote)
      }
    }
  }
}

// MARK: - Authorized State View
private extension AuthView {
  /// Displayed once the user has successfully signed in and completed MFA.
  /// Shows the user's ID token and provides actions for token refresh, user info,
  /// token details, and sign-out.
  private var successView: some View {
    VStack(spacing: 16) {
      Text("Signed in 🎉")
        .font(.title2)
        .bold()

      // Scrollable ID token display (for demo purposes)
      ScrollView {
        Text(Credential.default?.token.idToken?.rawValue ?? "(no id token)")
          .font(.footnote)
          .textSelection(.enabled)
          .padding()
          .background(.thinMaterial)
          .cornerRadius(8)
      }
      .frame(maxHeight: 220)

      // Authenticated user actions
      signoutButton
    }
    .padding()
  }
}

// MARK: - Action Buttons
private extension AuthView {
  /// Signs the user out, revoking tokens and returning to the login form.
  var signoutButton: some View {
    Button("Sign Out") {
      Task { await viewModel.signOut() }
    }
    .font(.system(size: 14))
  }
}

With this added, you will receive an error stating that WaitingForPushView can’t be found in scope. To fix this, we need to add that view next. Add a new empty Swift file in the Views folder and name it WaitingForPushView. When complete, add the following implementation inside:

import SwiftUI

struct WaitingForPushView: View {
  let onCancel: () -> Void

  var body: some View {
    VStack(spacing: 16) {
      ProgressView()
      Text("Approve the Okta Verify push on your device.")
        .multilineTextAlignment(.center)

      Button("Cancel", action: onCancel)
    }
    .padding()
  }
}

Now you can run the application on a simulator, and it should present you with the option to log in first with a username and password. After selecting SignIn, it will redirect to the “Waiting for push notification” screen and remain active until you acknowledge the request from the Okta Verify App. If you’re logged in, you’ll see the access token and a sign-out button.

Read ID token info

Once your app authenticates a user with Okta DirectAuth, the resulting credentials are securely stored in the device’s keychain through AuthFoundation.

These credentials include access, ID, and (optionally) refresh tokens – all essential for securely calling APIs or verifying user identity.

In this section, we’ll create a skeleton TokenInfoView that reads the current tokens from Credential.default and displays them in a developer-friendly format.

This view helps visualize the credential in the store and to inspect the scope. And it helps verify that the authentication flow works.

Create a new Swift file in the Views folder and name it TokenInfoView. Add the following code:

import SwiftUI
import AuthFoundation

/// Displays detailed information about the tokens stored in the current
/// `Credential.default` instance. This view is helpful for debugging and
/// validating your DirectAuth flow -- confirming that tokens are correctly
/// issued, stored, and refreshed.
///
/// ⚠️ **Important:** Avoid showing full token strings in production apps.
/// Tokens should be treated as sensitive secrets.
struct TokenInfoView: View {

  /// Retrieves the current credential object managed by `AuthFoundation`.
  /// If the user is signed in, this will contain their access, ID, and refresh tokens.
  private var credential: Credential? { Credential.default }

  /// Used to dismiss the current view when the close button is tapped.
  @Environment(\.dismiss) var dismiss

  var body: some View {
    ScrollView {
        VStack(alignment: .leading, spacing: 20) {

          // MARK: - Close Button
          // Dismisses the token info view when tapped.
          Button {
            dismiss()
          } label: {
            Image(systemName: "xmark.circle.fill")
              .resizable()
              .foregroundStyle(.black)
              .frame(width: 40, height: 40)
              .padding(.leading, 10)
          }

          // MARK: - Token Display
          // Displays the token information as formatted monospaced text.
          // If no credential is available, a "No token found" message is shown.
          Text(credential?.toString() ?? "No token found")
            .font(.system(.body, design: .monospaced))
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
        }
    }
    .background(Color(.systemGroupedBackground))
    .navigationTitle("Token Info")
    .navigationBarTitleDisplayMode(.inline)
  }
}

// MARK: - Credential Display Helper

extension Credential {
  /// Returns a formatted string representation of the stored token values.
  /// Includes access, ID, and refresh tokens as well as their associated scopes.
  ///
  /// - Returns: A multi-line string suitable for debugging and display in `TokenInfoView`.
  func toString() -> String {
    var result = ""

    result.append("Token type: \(token.tokenType)")
    result.append("\n\n")

    result.append("Access Token: \(token.accessToken)")
    result.append("\n\n")

    result.append("Scopes: \(token.scope?.joined(separator: ",") ?? "No scopes found")")
    result.append("\n\n")

    if let idToken = token.idToken {
      result.append("ID Token: \(idToken.rawValue)")
      result.append("\n\n")
    }

    if let refreshToken = token.refreshToken {
      result.append("Refresh Token: \(refreshToken)")
      result.append("\n\n")
    }

    return result
  }
}

To view this on screen, we need to instruct SwiftUI to present it. We added the State variable in the AuthView for this purpose - it’s named showTokenInfo. Next, we need to add a button to present the TokenInfoView. Go to the AuthView.swift and scroll down to the last private extension where it says “Action Buttons” and add the following button:

/// Opens the full-screen view showing token info.
var tokenInfoButton: some View {
  Button("Token Info") {
    showTokenInfo = true
  }
  .disabled(viewModel.isLoading)
}

Now that this is in place, we need to tell SwiftUI that we want to present TokenInfoView whenever the showTokenInfo boolean is true. In the AuthView, find the body and add this code at the end below the .padding():

// Show Token Info full screen
.fullScreenCover(isPresented: $showTokenInfo) {
  TokenInfoView()
}

If you build and run the app, you’ll no longer see the Token Info button when logged in. To keep the button visible, we also need to reference the tokenInfoButton in the successView. In the AuthView file, scroll down to “Authorized State View” (successView) and reference the button just above the signoutButton like this:

private var successView: some View {
  VStack(spacing: 16) {
    Text("Signed in 🎉")
      .font(.title2)
      .bold()

    // Scrollable ID token display (for demo purposes)
    ScrollView {
      Text(Credential.default?.token.idToken?.rawValue ?? "(no id token)")
        .font(.footnote)
        .textSelection(.enabled)
        .padding()
        .background(.thinMaterial)
        .cornerRadius(8)
    }
    .frame(maxHeight: 220)

    // Authenticated user actions
    tokenInfoButton // this is added
    signoutButton
  }
  .padding()
}

Try building and running the app. You should now see the Token Info button after logging in. Tapping the button should open the Token Info View.

View the authenticated user’s profile info

Once your app authenticates a user with Okta DirectAuth, it can use the stored credentials to request profile information from the UserInfo endpoint securely.

This endpoint returns standard OpenID Connect (OIDC) claims, including the user’s name, email address, and unique identifier (sub).

In this section, you’ll add a User Info button to your authenticated view and implement a corresponding UserInfoView that displays these profile details.

This is a quick and powerful way to confirm the validity of the access token and that your app can retrieve user data after sign-in.

Create a new empty Swift file in the Views folder and name it UserInfoView. Then add the following code:

import SwiftUI
import AuthFoundation

/// A view that displays the authenticated user's profile information
/// retrieved from Okta's **UserInfo** endpoint.
///
/// The `UserInfo` object is provided by **AuthFoundation** and contains
/// standard OpenID Connect (OIDC) claims such as `name`, `preferred_username`,
/// and `sub` (subject identifier). This view is shown after the user has
/// successfully authenticated, allowing you to confirm that your access token
/// can retrieve user data.
struct UserInfoView: View {

  /// The user information returned by the Okta UserInfo endpoint.
  let userInfo: UserInfo

  /// Used to dismiss the view when the close button is tapped.
  @Environment(\.dismiss) var dismiss

  var body: some View {
    ScrollView {
      VStack(alignment: .leading, spacing: 20) {

          // MARK: - Close Button
          // Dismisses the full-screen user info view.
          Button {
            dismiss()
          } label: {
            Image(systemName: "xmark.circle.fill")
              .resizable()
              .foregroundStyle(.black)
              .frame(width: 40, height: 40)
              .padding(.leading, 10)
          }

          // MARK: - User Information Text
          // Displays formatted user claims (name, username, subject, etc.)
          Text(formattedData)
            .font(.system(size: 14))
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding()
        }
    }
    .background(Color(.systemBackground))
    .navigationTitle("User Info")
    .navigationBarTitleDisplayMode(.inline)
  }

  // MARK: - Data Formatting

  /// Builds a simple multi-line string of readable user information.
  /// Extracts common OIDC claims and formats them for display.
  private var formattedData: String {
    var result = ""

    // User's full name
    result.append("Name: " + (userInfo.name ?? "No name set"))
    result.append("\n\n")

    // Preferred username (email or login identifier)
    result.append("Username: " + (userInfo.preferredUsername ?? "No username set"))
    result.append("\n\n")

    // Subject identifier (unique Okta user ID)
    result.append("User ID: " + (userInfo.subject ?? "No ID found"))
    result.append("\n\n")

    // Last updated timestamp (if available)
    if let updatedAt = userInfo.updatedAt {
      let dateFormatter = DateFormatter()
      dateFormatter.dateStyle = .medium
      dateFormatter.timeStyle = .short
      let formattedDate = dateFormatter.string(for: updatedAt)
      result.append("Updated at: " + (formattedDate ?? ""))
    }

    return result
  }
}

Once again, to display this in our app, we need to add a new button to show the new view. To do that, open the AuthView.swift, scroll down to the last private extension where it says “Action Buttons”, and add the following button just below the tokenInfoButton:

/// Loads user info and presents it full screen.
@MainActor
var userInfoButton: some View {
  Button("User Info") {
    Task {
      if let user = await viewModel.fetchUserInfo() {
        userInfo = UserInfoModel(user: user)
      }
    }
  }
  .font(.system(size: 14))
  .disabled(viewModel.isLoading)
}

Next, we need to add the button to the successView like we did with the tokenInfoButton. Then, we will use the userInfo property in the AuthView, which we added at the start. Navigate to the AuthView.swift file and find the successView in the “Authorized State View” mark and reference the userInfoButton after the tokenInfoButton like this:

private var successView: some View {
  VStack(spacing: 16) {
    Text("Signed in 🎉")
      .font(.title2)
      .bold()

    // Scrollable ID token display (for demo purposes)
    ScrollView {
      Text(Credential.default?.token.idToken?.rawValue ?? "(no id token)")
        .font(.footnote)
        .textSelection(.enabled)
        .padding()
        .background(.thinMaterial)
        .cornerRadius(8)
    }
    .frame(maxHeight: 220)

    // Authenticated user actions
    tokenInfoButton
    userInfoButton // this is added
    signoutButton
  }
  .padding()
}

We need to tell SwiftUI that we want to open a new UserInfoView whenever the value on the userInfo property changes. To do so, open the AuthView and find the body variable, add the following code after the last closing bracket:

// Show User Info full screen
.fullScreenCover(item: $userInfo) { info in
  UserInfoView(userInfo: info.user)
}

The body of your AuthView should look like this now:

var body: some View {
  VStack {
    // Render the UI based on the current authentication state.
    // Each case corresponds to a different phase of the DirectAuth flow.
    switch viewModel.state {
    case .idle, .failed:
      loginForm
    case .authenticating:
      ProgressView("Signing in...")
    case .waitingForPush:
      // Waiting for Okta Verify push approval
      WaitingForPushView {
        Task { await viewModel.signOut() }
      }
    case .authorized:
      successView
    }

    if viewModel.isLoading {
      ProgressView()
    }
  }
  .padding()
  // Show Token Info full screen
  .fullScreenCover(isPresented: $showTokenInfo) {
    TokenInfoView()
  }
  // Show User Info full screen
  .fullScreenCover(item: $userInfo) { info in
    UserInfoView(userInfo: info.user)
  }
}
Keeping tokens refreshed and maintaining user sessions

Access tokens have a limited lifetime to ensure your app’s security. When a token expires, the user shouldn’t have to sign-in again – instead, your app can request a new access token using the refresh token stored in the credential.

In this section, you’ll add support for token refresh, allowing users to stay authenticated without repeating the entire sign-in and MFA flow.

You’ll add an action in the UI that calls the refreshTokenIfNeeded() method from your AuthService, which silently exchanges the refresh token for a new set of valid tokens. We’re making this call manually, but you can watch for upcoming expiry and refresh the token before it happens preemptively. While we don’t show it here, you should use Refresh Token Rotation to ensure refresh tokens are also short-lived as a security measure.

First, we need to add the refreshTokenButton, which we’ll add to the AuthView. Open the AuthView, scroll down to the last private extension in the “Action Buttons” mark, and add the following button at the end of the extension:

/// Refresh Token if needed
var refreshTokenButton: some View {
  Button("Refresh Token") {
    Task { await viewModel.refreshToken() }
  }
  .font(.system(size: 14))
  .disabled(viewModel.isLoading)
}

Next, we need to reference the button somewhere in our view. We will do that inside the successView, like we did with the other buttons. Find the successView and add the button. Your successView should look like this:

private var successView: some View {
  VStack(spacing: 16) {
    Text("Signed in 🎉")
        .font(.title2)
        .bold()

    // Scrollable ID token display (for demo purposes)
    ScrollView {
      Text(Credential.default?.token.idToken?.rawValue ?? "(no id token)")
        .font(.footnote)
        .textSelection(.enabled)
        .padding()
        .background(.thinMaterial)
        .cornerRadius(8)
    }
    .frame(maxHeight: 220)

    // Authenticated user actions
    tokenInfoButton
    userInfoButton
    refreshTokenButton // this is added
    signoutButton
  }
  .padding()
}

Now, if you run the app and tap the refreshTokenButton, you should see your token change in the token preview label.

One thing that we didn’t implement and left with a default implementation to return nil is the accessToken property on the AuthService. Navigate to the AuthService, find the accessToken property, and replace the code so it looks like this:

var accessToken: String? {
  switch state {
  case .authorized(let token):
    return token.accessToken
  default:
    return nil
  }
}

Currently, if you restart the app, you’ll get a prompt to log in each time. This is not a good user experience, and the user should remain logged in. We can add this feature by adding code in the AuthService initializer. Open your AuthService class and replace the init function with the following:

init() {
  // Prefer PropertyListConfiguration if Okta.plist exists; otherwise fall back
  if let configuration = try? OAuth2Client.PropertyListConfiguration() {
    self.flow = try? DirectAuthenticationFlow(client: OAuth2Client(configuration))
  } else {
    self.flow = try? DirectAuthenticationFlow()
  }

  // Added
  if let token = Credential.default?.token {
    state = .authorized(token)
  }
}
Build your own secure native sign-in iOS app

You’ve now built a fully native authentication flow on iOS using Okta DirectAuth with push notification MFA – no browser redirects required. You can check your work against the GitHub repo for this project.

Your app securely signs users in, handles multi-factor verification through Okta Verify, retrieves user profile details, displays token information, and refreshes tokens to maintain an active session. By combining AuthFoundation and OktaDirectAuth, you’ve implemented a modern, phishing-resistant authentication system that balances strong security with a seamless user experience – all directly within your SwiftUI app.

If you found this post interesting, you may want to check out these resources:

Follow OktaDev on Twitter and subscribe to our YouTube channel to learn about secure authentication and other exciting content. We also want to hear from you about topics you want to see and questions you may have. Leave us a comment below!

https://developer.okta.com/blog/2025/11/18/okta-ios-directauth
Stretch Your Imagination and Build a Delightful Sign-In Experience

When you choose Okta as your IAM provider, one of the features you get access to is customizing your Okta-hosted Sign-In Widget (SIW), which is our recommended method for the highest levels of identity security. It’s a customizable JavaScript component that provides a ready-made login interface you can use immediately as part of your web application.

The Okta Identity Engine (OIE) utilizes authentication policies to drive authentication challenges, and the SIW supports various authentication factors, ranging from basic username and password login to more advanced scenarios, such as multi-factor authentication, biometrics, passkeys, social login, account registration, account recovery, and more. Under the hood, it interacts with Okta’s APIs, so you don’t have to build or manage complex auth logic yourself. It’s all handled for you!

One of the perks of using the Okta SIW, especially with the 3rd Generation Standard (Gen3), is that customization is a configuration thanks to design tokens, so you don’t have to write CSS to style the widget elements.

Style the Okta Sign-In Widget to match your brand

In this tutorial, we will customize the Sign In Widget for a fictional to-do app. We’ll make the following changes:

  • Replace font selections
  • Define border, error, and focus colors
  • Remove elements from the SIW, such as the horizontal rule and add custom elements
  • Shift the control to the start of the site and add a background panel

Without any changes, when you try to sign in to your Okta account, you see something like this:

Default Okta-hosted Sign-In Widget

At the end of the tutorial, your login screen will look something like this 🎉

A customized Okta-hosted Sign-In Widget with custom elements, colors, and styles

We’ll use the SIW gen3 along with new recommendations to customize form elements and style using design tokens.

Table of Contents

Prerequisites To follow this tutorial, you need:

  • An Okta account with the Identity Engine, such as the Integrator Free account. The SIW version in the org we’re using is 7.36.
  • Your own domain name
  • A basic understanding of HTML, CSS, and JavaScript
  • A brand design in mind. Feel free to tap into your creativity!

Let’s get started!

Customize your Okta-hosted sign-in page

Before we begin, you must configure your Okta org to use your custom domain. Custom domains enable code customizations, allowing us to style more than just the default logo, background, favicon, and two colors. Sign in as an admin and open the Okta Admin Console, navigate to Customizations > Brands and select Create Brand +.

Follow the Customize domain and email developer docs to set up your custom domain on the new brand.

You can also follow this post if you prefer.

A Secure and Themed Sign-in Page

Redirecting to the Okta-hosted sign-in page is the most secure way to authenticate users in your application. But the default configuration yield a very neutral sign-in page. This post walks you through customization options and setting up a custom domain so the personality of your site shines all through the user's experience.

avatar-avatar-alisa_duncan.jpeg Alisa Duncan

Once you have a working brand with a custom domain, select your brand to configure it. First, navigate to Settings and select Use third generation to enable the SIW Gen3. Save your selection.

⚠️ Note

The code in this post relies on using SIW Gen3. It will not work on SIW Gen2.

Navigate to Theme. You’ll see a default brand page that looks something like this:

Default styles for the Okta-hosted SIW

Let’s start making it more aligned with the theme we have in mind. Change the primary and secondary colors, then the logo and favicon images with your preferred options

To change either color, click on the text field and enter the hex code for each. We’re going for a bold and colorful approach, so we’ll use #ea3eda as the primary color and #ffa738 as the secondary color, and upload the logo and favicon images for the brand. Select Save.

Take a look at your sign-in page now by navigating to the sign-in URL for the brand. With your configuration, the sign-in widget looks more interesting than the default view, but we can make things even more exciting.

Let’s dive into the main task, customizing the signup page. On the Theme tab:

  1. Select Sign-in Page in the dropdown menu
  2. Select the Customize button
  3. On the Page Design tab, select the Code editor toggle to see a HTML page

Note: You can only enable the code editor if you configure a custom domain.

Understanding the Okta-hosted Sign-In Widget default code

If you’re familiar with basic HTML, CSS, and JavaScript, the sign-in code appears standard, although it’s somewhat unusual in certain areas. There are two major blocks of code we should examine: the top of the body tag on the page and the sign-in configuration in the script tag.

The first one looks something like this:

<div id="okta-login-container"></div>

The second looks like this:

var config = OktaUtil.getSignInWidgetConfig();

// Render the Okta Sign-In Widget
var oktaSignIn = new OktaSignIn(config);
oktaSignIn.renderEl({ el: '#okta-login-container' },
  OktaUtil.completeLogin,
  function(error) {
    // Logs errors that occur when configuring the widget.
    // Remove or replace this with your own custom error handler.
    console.log(error.message, error);
  }
);

Let’s take a closer look at how this code works. In the HTML, there’s a designated parent element that the OktaSignIn instance uses to render the SIW as a child node. This means that when the page loads, you’ll see the <div id="okta-login-container"></div> in the DOM with the HTML elements for SIW functionality as its child within the div. The SIW handles all authentication and user registration processes as defined by policies, allowing us to focus entirely on customization.

To create the SIW, we need to pass in the configuration. The configuration includes properties like the theme elements and messages for labels. The method renderEl() identifies the HTML element to use for rendering the SIW. We’re passing in #okta-login-container as the identifier.

The #okta-login-container is a CSS selector. While any correct CSS selector works, we recommend you use the ID of the element. Element IDs must be unique within the HTML document, so this is the safest and easiest method.

Customize the UI elements within the Okta Sign-In Widget

Now that we have a basic understanding of how the Okta Sign-In Widget works, let’s start customizing the code. We’ll start by customizing the elements within the SIW. To manipulate the Okta SIW DOM elements in Gen3, we use the afterTransform method. The afterTransform method allows us to remove or update elements for individual or all forms.

Find the button Edit on the Code editor view, which makes the code editor editable and behaves like a lightweight IDE.

Below the oktaSignIn.renderEl() method within the <script> tag, add

oktaSignIn.afterTransform('identify', ({ formBag }) => {
  const title = formBag.uischema.elements.find(ele => ele.type === 'Title');
  if (title) {
    title.options.content = "Log in and create a task";
  }

  const help = formBag.uischema.elements.find(ele => ele.type === 'Link' && ele.options.dataSe === 'help');
  const unlock = formBag.uischema.elements.find(ele => ele.type === 'Link' && ele.options.dataSe === 'unlock');
  const divider = formBag.uischema.elements.find(ele => ele.type === 'Divider');
  formBag.uischema.elements = formBag.uischema.elements.filter(ele => ![help, unlock, divider].includes(ele));
});

This afterTransform hook only runs before the ‘identify’ form. We can find and target UI elements using the FormBag. The afterTransform hook is a more streamlined way to manipulate DOM elements within the SIW before rendering the widget. For example, we can search elements by type to filter them out of the view before rendering, which is more performant than manipulating DOM elements after SIW renders. We filtered out elements such as the unlock account element and dividers in this snippet.

Let’s take a look at what this looks like. Press Save to draft and Publish.

Navigate to your sign-in URL for your brand to view the changes you made. When we compare to the default state, we no longer see the horizontal rule below the logo or the “Help” link. The account unlock element is no longer available.

We explored how we can customize the widget elements. Now, let’s add some flair.

Organize your Sign-In Widget customizations with CSS Custom properties

At its core, we’re styling an HTML document. This means we operate on the SIW customization in the same way as we would any HTML page, and code organization principles still apply. We can define customization values as CSS Custom properties (also known as CSS variables).

Defining styles using CSS variables keeps our code DRY. Setting up style values for reuse even extends beyond the Okta-hosted sign-in page. If your organization hosts stylesheets with brand color defined as CSS custom properties publicly, you can use the colors defined there and link your stylesheet.

Before making code edits, identify the fonts you want to use for your customization. We found a header and body font to use.

Open the SIW code editor for your brand and select Edit to make changes.

Import the fonts into the HTML. You can <link> or @import the fonts based on your preference. We added the <link> instructions to the <head> of the HTML.

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:ital,wght@0,100..900;1,100..900&family=Poiret+One&display=swap" rel="stylesheet">

Find the <style nonce="{{nonceValue}}"> tag. Within the tag, define your properties using the :root selector:

:root {
    --color-gray: #4f4f4f;
    --color-fuchsia: #ea3eda;
    --color-orange: #ffa738;
    --color-azul: #016fb9;
    --color-cherry: #ea3e84;
    --color-purple: #b13fff;
    --color-black: #191919;
    --color-white: #fefefe;
    --color-bright-white: #fff;
    --border-radius: 4px;
    --font-header: 'Poiret One', sans-serif;
    --font-body: 'Inter Tight', sans-serif;
 }

Feel free to add new properties or replace the property value for your brand. Now is a good opportunity to add your own brand colors and customizations!

Let’s configure the SIW with our variables using design tokens.

Find var config = OktaUtil.getSignInWidgetConfig();. After this line of code, set the values of the design tokens using your CSS Custom properties. You’ll use the var() function to access your variables:

config.theme = {
  tokens: {
    BorderColorDisplay: 'var(--color-bright-white)',
    PalettePrimaryMain: 'var(--color-fuchsia)',
    PalettePrimaryDark: 'var(--color-purple)',
    PalettePrimaryDarker: 'var(--color-purple)',
    BorderRadiusTight: 'var(--border-radius)',
    BorderRadiusMain: 'var(--border-radius)',
    PalettePrimaryDark: 'var(--color-orange)',
    FocusOutlineColorPrimary: 'var(--color-azul)',
    TypographyFamilyBody: 'var(--font-body)',
    TypographyFamilyHeading: 'var(--font-header)',
    TypographyFamilyButton: 'var(--font-body)',
    BorderColorDangerControl: 'var(--color-cherry)'
  }
}

Save your changes, publish the page, and view your brand’s sign-in URI site. Yay! You see, there’s no border outline, the border radius of the widget and HTML elements changed, a different focus color, and a different color for element outlines when there’s a form error. You can inspect the HTML elements and view the computed styles. Or if you prefer, feel free to update the CSS variables to something more visible.

When you inspect your brand’s sign-in URL site, you’ll notice that the fonts aren’t loading properly and that there are errors in your browser’s debugging console. This is because you need to configure Content Security Policies (CSP) to allow resources loaded from external sites. CSPs are a security measure to mitigate cross-site scripting (XSS) attacks. You can read An Overview of Best Practices for Security Headers to learn more about CSPs.

Navigate to the Settings tab for your brand’s Sign-in page. Find the Content Security Policy and press Edit. Add the domains for external resources. In our example, we only load resources from Google Fonts, so we added the following two domains:

*.googleapis.com
*.gstatic.com

Press Save to draft and press Publish to view your changes. The SIW now displays the fonts you selected!

Extending the SIW theme with a custom color palette

In our example, we selectively added colors. The SIW design system adheres to WCAG accessibility standards and relies on Material Design color palettes.

Okta generates colors based on your primary color that conform to accessibility standards and contrast requirements. Check out Understand Sign-In Widget color customization to learn more about color contrast and how Okta color generation works. You must supply accessible colors to the configuration.

Material Design supports themes by customizing color palettes. The list of all configurable design tokens displays all available options, including Hue* properties for precise color control. Consider exploring color palette customization options tailored to your brand’s specific needs. You can use Material palette generators such as this color picker from the Google team or an open source Material Design Palette Generator that allows you to enter a HEX color value.

Don’t forget to keep accessibility in mind. You can run an accessibility audit using Lighthouse in the Chrome browser and the WebAIM Contrast Checker. Our selected primary color doesn’t quite meet contrast requirements. 😅

Add custom HTML elements to the Sign-In Widget

Previously, we filtered HTML elements out of the SIW. We can also add new custom HTML elements to SIW. We’ll experiment by adding a link to the Okta Developer blog. Find the afterTransform() method. Update the afterTransform() method to look like this:

oktaSignIn.afterTransform('identify', ({formBag}) => {
  const title = formBag.uischema.elements.find(ele => ele.type === 'Title');
  if (title) {
    title.options.content = "Log in and create a task";
  }

  const help = formBag.uischema.elements.find(ele => ele.type === 'Link' && ele.options.dataSe === 'help');
  const unlock = formBag.uischema.elements.find(ele => ele.type === 'Link' && ele.options.dataSe === 'unlock');
  const divider = formBag.uischema.elements.find(ele => ele.type === 'Divider');
  formBag.uischema.elements = formBag.uischema.elements.filter(ele => ![help, unlock, divider].includes(ele));

  const blogLink = {
    type: 'Link',
    contentType: 'footer', 
    options: {
      href: 'https://developer.okta.com/blog',
      label: 'Read our blog',
      dataSe: 'blogCustomLink'
    }
  };
  formBag.uischema.elements.push(blogLink);
});

We created a new element named blogLink and set properties such as the type, where the content resides, and options related to the type. We also added a dataSe property that adds the value blogCustomLink to an HTML data attribute. Doing so makes it easier for us to select the element for customization or for testing purposes.

When you continue past the ‘identify’ form in the sign-in flow, you’ll no longer see the link to the blog.

Overriding Okta Sign-In Widget element styles

We should use design tokens for customizations wherever possible. In cases where a design token isn’t available for your styling needs, you can fall back to defining style manually.

Let’s start with the element we added, the blog link. Let’s say we want to display the text in capital casing. It’s not good practice to define the label value using capital casing for accessibility. We should use CSS to transform the text.

In the styles definition, find the #login-bg-image-id. After the styles for the background image, add the style to target the blogCustomLink data attribute and define the text transform like this:

a[data-se="blogCustomLink"] {
    text-transform: uppercase;
}

Save and publish the page to check out your changes.

Now, let’s say you want to style an Okta-provided HTML element. Use design tokens wherever possible, and make style changes cautiously as doing so adds brittleness and security concerns.

Here’s a terrible example of styling an Okta-provided HTML element that you shouldn’t emulate, as it makes the text illegible. Let’s say you want to change the background of the Next button to be a gradient. 🌈

Inspect the SIW element you want to style. We want to style the button with the data attribute okta-sign-in-header.

After the blogCustomLink style, add the following:

button[data-se="save"] {
    background: linear-gradient(12deg, var(--color-fuchsia) 0%, var(--color-orange) 100%);
}

Save and publish the site. The button background is now a gradient.

However, style the Okta-provided SIW elements with caution. The dangers with this approach are two-fold:

  1. The Okta Sign-in widget undergoes accessibility audits, and changing styles and behavior manually may decrease accessibility thresholds
  2. The Okta Sign-in widget is internationalized, and changing styles around text layout manually may break localization needs
  3. Okta can’t guarantee that the data attributes or DOM elements remain unchanged, leading to customization breaks

In the rare case where you style an Okta-provided SIW element you may need to pin the SIW version so your customizations don’t break from under you. Navigate to the Settings tab and find the Sign-In Widget version section. Select Edit and select the most recent version of the widget, as this one should be compatible with your code. We are using widget version 7.36 in this post.

⚠️ Note

When you pin the widget, you won’t get the latest and greatest updates from the SIW without manually updating the version. Pinning the version prevents any forward progress in the evolution and extensibility of the end-user experiences. For the most secure option, allow SIW to update automatically and avoid overly customizing the SIW with CSS. Use the design tokens wherever possible.

Change the layout of the Okta-hosted Sign-In page

We left the HTML nodes defined in the SIW customization unedited so far. You can change the layout of the default <div> containers to make a significant impact. Change the display CSS property to make an impactful change, such as using Flexbox or CSS Grid. I’ll use Flexbox in this example.

Find the div for the background image container and the okta-login-container. Replace those div elements with this HTML snippet:

<div id="login-bg-image-id" class="login-bg-image tb--background">
    <div class="login-container-panel">
        <div id="okta-login-container"></div>
    </div>
</div>

We moved the okta-login-container div inside another parent container and made it a child of the background image container.

Find #login-bg-image style. Add the display: flex; property. The styles should look like this:

 #login-bg-image-id {
     background-image: {{bgImageUrl}};
     display: flex;
}

We want to style the okta-login-container’s parent <div> to set the background color and to center the SIW on the panel. Add new styles for the login-container-panel class:

.login-container-panel {
    background: var(--color-white);
    display: flex;
    justify-content: center;
    align-items: center;
    width: 40%;
    min-width: 400px;
}

Save your changes and view the sign-in page. What do you think of the new layout? 🎊

⚠️ Note

Flexbox and CSS Grid are responsive, but you may still need to add properties handling responsiveness or media queries to fit your needs.

Your final code might look something like this:

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>

<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="robots" content="noindex,nofollow" />
    <!-- Styles generated from theme -->
    <link href="{{themedStylesUrl}}" rel="stylesheet" type="text/css">
    <!-- Favicon from theme -->
    <link rel="shortcut icon" href="{{faviconUrl}}" type="image/x-icon">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter+Tight:ital,wght@0,100..900;1,100..900&family=Poiret+One&display=swap" rel="stylesheet">    

    <title>{{pageTitle}}</title>
    {{{SignInWidgetResources}}}

    <style nonce="{{nonceValue}}">
        :root {
            --font-header: 'Poiret One', sans-serif;
            --font-body: 'Inter Tight', sans-serif;
            --color-gray: #4f4f4f;
            --color-fuchsia: #ea3eda;
            --color-orange: #ffa738;
            --color-azul: #016fb9;
            --color-cherry: #ea3e84;
            --color-purple: #b13fff;
            --color-black: #191919;
            --color-white: #fefefe;
            --color-bright-white: #fff;
            --border-radius: 4px;
        }

        {{ #useSiwGen3 }}

        html {
            font-size: 87.5%;
        }

        {{ /useSiwGen3 }}

        #login-bg-image-id {
            background-image: {{bgImageUrl}};
            display: flex;
        }
   
       .login-container-panel {
            background: var(--color-white);
            display: flex;
            justify-content: center;
            align-items: center;
            width: 40%;
            min-width: 400px;
        }

        a[data-se="blogCustomLink"] {
            text-transform: uppercase;
        }
    </style>
</head>

<body>
   <div id="login-bg-image-id" class="login-bg-image tb--background">
        <div class="login-container-panel">
            <div id="okta-login-container"></div>
        </div>
    </div>



    <!--
   "OktaUtil" defines a global OktaUtil object
   that contains methods used to complete the Okta login flow.
-->
    {{{OktaUtil}}}


    <script type="text/javascript" nonce="{{nonceValue}}">
        // "config" object contains default widget configuration
        // with any custom overrides defined in your admin settings.

        const config = OktaUtil.getSignInWidgetConfig();
        config.theme = {
            tokens: {
                BorderColorDisplay: 'var(--color-bright-white)',
                PalettePrimaryMain: 'var(--color-fuchsia)',
                PalettePrimaryDark: 'var(--color-purple)',
                PalettePrimaryDarker: 'var(--color-purple)',
                BorderRadiusTight: 'var(--border-radius)',
                BorderRadiusMain: 'var(--border-radius)',
                PalettePrimaryDark: 'var(--color-orange)',
                FocusOutlineColorPrimary: 'var(--color-azul)',
                TypographyFamilyBody: 'var(--font-body)',
                TypographyFamilyHeading: 'var(--font-header)',
                TypographyFamilyButton: 'var(--font-body)',
                BorderColorDangerControl: 'var(--color-cherry)'
            }
        }

        // Render the Okta Sign-In Widget
        const oktaSignIn = new OktaSignIn(config);
        oktaSignIn.renderEl({ el: '#okta-login-container' },
            OktaUtil.completeLogin,
            function (error) {
                // Logs errors that occur when configuring the widget.
                // Remove or replace this with your own custom error handler.
                console.log(error.message, error);
            }
        );

        oktaSignIn.afterTransform('identify', ({ formBag }) => {
            const title = formBag.uischema.elements.find(ele => ele.type === 'Title');
            if (title) {
                title.options.content = "Log in and create a task";
            }

            const help = formBag.uischema.elements.find(ele => ele.type === 'Link' && ele.options.dataSe === 'help');
            const unlock = formBag.uischema.elements.find(ele => ele.type === 'Link' && ele.options.dataSe === 'unlock');
            const divider = formBag.uischema.elements.find(ele => ele.type === 'Divider');
            formBag.uischema.elements = formBag.uischema.elements.filter(ele => ![help, unlock, divider].includes(ele));

            const blogLink = {
                type: 'Link',
                contentType: 'footer',
                options: {
                    href: 'https://developer.okta.com/blog',
                    label: 'Read our blog',
                    dataSe: 'blogCustomLink'
                }
            };
            formBag.uischema.elements.push(blogLink);
        });

    </script>


</body>

</html>

You can also find the code in the GitHub repository for this blog post. With these code changes, you can connect this with an app to see how it works end-to-end. You’ll need to update your Okta OpenID Connect (OIDC) application to work with the domain. In the Okta Admin Console, navigate to Applications > Applications and find the Okta application for your custom app. Navigate to the Sign On tab. You’ll see a section for OpenID Connect ID Token. Select Edit and select Custom URL for your brand’s sign-in URL as the Issuer value.

You’ll use the issuer value, which matches your brand’s custom URL, and the Okta application’s client ID in your custom app’s OIDC configuration. If you want to try this and don’t have a pre-built app, you can use one of our samples, such as the Okta React sample.

Customize your Gen3 Okta-hosted Sign-In Widget

I hope you enjoyed customizing the sign-in experience for your brand. Using the Okta-hosted Sign-In widget is the best, most secure way to add identity security to your sites. With all the configuration options available, you can have a highly customized sign-in experience with a custom domain without anyone knowing you’re using Okta.

If you like this post, there’s a good chance you’ll find these links helpful:

Remember to follow us on Twitter and subscribe to our YouTube channel for fun and educational content. We also want to hear from you about topics you want to see and questions you may have. Leave us a comment below! Until next time!

https://developer.okta.com/blog/2025/11/12/custom-signin