/images/resources/tutorials/securing-ai-agents/chatgpt-step-up-authentication.png

Secure an OpenAI ChatGPT App

On this page

The initial Implement MCP Authorization code example demonstrates how to put together a working Model Context Protocol (MCP) authorization flow. As a result, AI agents can onboard and gain a restricted level of access to APIs. Users approve the AI agent's level of access, which depreciates to zero standing privilege after a short time.

AI agents are a new type of user agent. The MCP clients that the AI agent runs can download MCP resources from the MCP server. Resources can be HTML forms that run in the AI agent and call the MCP server, in a similar manner to browser-based applications.

This code example uses the OpenAI Apps SDK to build a ChatGPT widget app that runs in an iframe within ChatGPT's web interface. The code example is secured with a step-up authentication flow for an example financial use case.

Step-Up User Experience

First, the user configures the MCP server's URL in ChatGPT. Since the MCP server uses MCP authorization, ChatGPT triggers user authentication in the system browser. The example uses email authentication and provides some test user accounts.

After the user authenticates, ChatGPT receives a low privilege access token and calls the MCP server to load the widget app into an iframe. The user can then interact with the MCP server and ask a natural language question.

ChatGPT using the example MCP server

When the user requests their portfolio, ChatGPT determines the tool to call, sends the access token and gets the user's portfolio data. The widget receives the data and renders it in a form within the iframe.

ChatGPT View Portfolio Screen

Next, the user can choose to buy or sell stocks, to initiate a high-privilege operation. Therefore, the user must use Strong Customer Authentication (SCA) to enable the use of a high privilege access token. The user remains within the ChatGPT web interface, which renders a BankID animated QR code. The user opens their instance of the BankID app, selects the option to scan the QR code, and then approves the transaction.

ChatGPT BankID Confirmation

Video Walkthrough

The following short video visually demonstrates the flow and shows the user actions both within the ChatGPT app and within the BankID mobile app.

Step Up Authentication Flow

The following diagram shows the main authentication steps and the use of access tokens.

MCP step-up flow where the user approves an AI agent to complete a high privilege transaction
  1. ChatGPT's MCP client runs an initial email authentication flow, to get an access token with scope portfolio.
  2. ChatGPT's MCP client sends the access token in a tool request to initiate a buy or sell transaction.
  3. The MCP server runs an API-driven authentication flow that uses BankID as an authenticator.
  4. The MCP server receives multiple BankID QR codes.
  5. The ChatGPT widget receives MCP server responses and renders them as animated QR codes.
  6. The user opens the high security BankID login app, scans the QR code and authenticates with BankID.
  7. The flow completes and the MCP server receives an access token with the transactions high privilege scope.
  8. The MCP server calls the Portfolio API with the high privilege access token to complete the transaction.

During the flow, the ChatGPT widget polls the MCP server, which polls the Curity Identity Server, which polls BankID's servers. When the transaction completes, the MCP server returns a final API response to the widget. The widget then uses JavaScript to update its web interface with new stock balances.

The flow provides the following benefits:

  • Modern User Experience: Using the system browser to launch BankID would not enhance security and would result in an awkward user experience. Therefore, the step-up flow uses the Hypermedia Authentication API (HAAPI) to run an API-driven flow and enable the user to scan the BankID QR code from within the ChatGPT app.
  • Strong Security: Only the MCP server comes into contact with the high-privilege access token, which never leaves the backend environment. An alternative integration could use a BankID Signing Consentor to record the user consent in a non-repudiable manner.

MCP Server Operations

The MCP server implementation uses Node.js and the Express HTTP server to provide the following operations.

OperationDescription
portfolio_widgetAn MCP resource that ChatGPT calls when it connects to the MCP server.
get_portfolioAn MCP tool that ChatGPT calls, whose data the widget uses to render a form.
buy_stockAn MCP tool that the widget invokes when the user clicks a buy button in the form.
sell_stockAn MCP tool that the widget invokes when the user clicks a sell button in the form.
continue_authorizationAn MCP tool that the widget calls to poll for BankID completion.

The code implements logic specific to ChatGPT MCP Servers. The portfolio_widget operation serves a UI bundle with a MIME type of text/html+skybridge, to instruct ChatGPT to treat the payload as a sandboxed HTML entry point and inject the ChatGPT widget runtime.

typescript
123456789101112131415161718192021222324
const widgetAppBundle = readFileSync("dist/web/bundle.js", "utf8");
const css = readFileSync("dist/web/app.css", "utf8");
server.registerResource(
"portfolio-widget",
"ui://widget/portfolio-widget.html",
{},
async () => ({
contents: [
{
uri: "ui://widget/portfolio-widget.html",
mimeType: "text/html+skybridge",
text: `
<div id="root"></div>
<style>${css}</style>
<script type="module">${widgetAppBundle}</script>
`.trim(),
_meta: {
"openai/widgetPrefersBorder": true,
},
},
],
})
);

ChatGPT Widget App

The ChatGPT widget app is a React project that builds to a static UI bundle deployed with the MCP server. The React code uses the techniques from the OpenAI Apps Quickstart. When ChatGPT loads the widget into an iframe, the widget's window has an window.openai object that serves as a bridge to ChatGPT. The widget can then call window.openai.callTool to initiate fetch requests to the MCP server. The widget can read the window.openai.toolOutput object to process data from tool responses.

When the widget needs to trigger buying or selling of stocks it calls the corresponding MCP tool via the OpenAI bridge. The MCP server then begins a backend HAAPI flow and returns structured content to instruct the widget what to do next. Until BankID authentication completes, the response instructs the widget to render the BankID QR code and poll the MCP server again.

typescript
1234567891011121314151617181920212223242526
const updateStock = async (toolToCall: Tool, id: string, delta: number) => {
const updateStockResult = await window.openai?.callTool(toolToCall.name, { id, quantity: delta });
const newState = {} as any;
if (updateStockResult?.structuredContent?.authMessage) {
newState.authMessage = updateStockResult?.structuredContent.authMessage;
pollAuthentication(toolToCall);
}
if (updateStockResult?.structuredContent?.result) {
const stockToUpdate = updateStockResult.structuredContent.result as Stock;
newState.portfolio = widgetState?.portfolio.map(stock => {
if (stock.id === stockToUpdate.id) {
return stockToUpdate;
}
return stock;
});
}
setWidgetState({
...widgetState,
...newState
});
}

The polling operation is a recursive function that calls the MCP server every second to check if BankID authentication is complete. If not, the widget updates its state with the latest BankID QR code from the MCP server response, to trigger a re-render and achieve an animated effect. Eventually, authentication completes and the widget uses MCP server response data to render new balances. The following code shows an abridged version of the code.

typescript
12345678910111213141516171819202122232425262728293031
const pollAuthentication = async (originalTool: Tool) => {
await setTimeout(() => Promise.resolve(), 1000);
const toolResult = await window.openai?.callTool('continue_authorization', { });
switch (toolResult.structuredContent.authMessage.message ) {
case 'authentication_complete':
const originalToolResult = await window.openai?.callTool(originalTool.name, { id: originalTool.parameters.id, quantity: originalTool.parameters.delta });
setWidgetState({
...widgetState,
portfolio: originalToolResult.updatedPortfolio,
authMessage: undefined
});
break;
case 'authentication_failed':
reportError(toolResult);
break;
default:
setWidgetState({
...widgetState,
authMessage: toolResult?.structuredContent.authMessage,
});
pollAuthentication();
break;
}
}

MCP Server Code Flow

The MCP server receives a low-privilege access token from ChatGPT and then needs to initiate a step-up flow on behalf of the user in the access token. The MCP server triggers an API-driven code flow with the following main steps.

  1. ChatGPT's MCP client sends a tool request to the MCP server with the initial access token.
  2. The MCP server begins an API-driven code flow as an OAuth client with multi-factor authentication configuration.
  3. The access token authenticator runs first, to receive and verify the low privilege access token.
  4. The access token authenticator completes automatically and sets the authenticated subject from the access token.
  5. The BankID authenticator runs next, to poll BankID servers and wait for user authentication to complete.
  6. When the user confirms the transaction in BankID, the MCP server receives an authorization code.
  7. The MCP server makes a token request with the authorization code and the high privilege transactions scope.
  8. The MCP server receives a high privilege access token with which it can can call the Portfolio API.

The MCP server acts as an OAuth confidential client to run the HAAPI code flow. The following code shows how the flow begins with a sendAuthorizationRequest operation with the high privilege scope, and ends with a postAuthorizationCode operation to get the high privilege access token.

typescript
123456789101112131415161718192021222324252627
async function sendAuthorizationRequest(client: DPoPOAuthClient): Promise<Response> {
const url = new URL(ensureAbsoluteUrl(config.authorizationEndpoint));
url.searchParams.append('response_type', 'code');
url.searchParams.append('client_id', config.haapiClientId);
url.searchParams.append('redirect_uri', config.redirectUri);
url.searchParams.append('scope', config.scope);
url.searchParams.append('state', randomState);
url.searchParams.append('acr', config.acr);
return client.get(url.toString(), haapiHeaders)
}
async postAuthorizationCode(url: string, code: string, redirectUri: string): Promise<TokenResponse> {
const response = await this.postForm(
url,
{
grant_type: 'authorization_code',
code: code,
redirect_uri: redirectUri,
}, {
'Accept': 'application/json',
'Authorization': this.getBasicAuthHeader(),
}
);
return await response.json() as TokenResponse;
}

Access Token Authenticator

The Access Token Authenticator is an authenticator plugin for the Curity Identity Server. The following XML shows how to configure the plugin with the expected properties of the MCP client's access token.

xml
123456789101112131415161718192021222324252627282930
<config xmlns="http://tail-f.com/ns/config/1.0">
<profiles xmlns="https://curity.se/ns/conf/base">
<profile>
<id>authentication-service</id>
<type xmlns:auth="https://curity.se/ns/conf/profile/authentication">auth:authentication-service</type>
<settings>
<authentication-service xmlns="https://curity.se/ns/conf/profile/authentication">
<authenticators>
<authenticator>
<id>access-token</id>
<authentication-actions>
<login>second-factor-authentication</login>
<sso>second-factor-authentication</sso>
</authentication-actions>
<access-token xmlns="https://curity.se/ns/ext-conf/access-token">
<key-verification>
<id>default-signature-verification-key</id>
</key-verification>
<allowed-oauth-client-ids>mcp-server-haapi</allowed-oauth-client-ids>
<required-audience>https://mcp.demo.example/</required-audience>
<required-issuer>https://login.demo.example/oauth/v2/oauth-anonymous</required-issuer>
<required-scopes>portfolio</required-scopes>
</access-token>
</authenticator>
</authenticators>
</authentication-service>
</settings>
</profile>
</profiles>
</config>

The plugin's code extracts the sub claim from the access token and uses it to set the authenticated subject and complete first-factor authentication. The access token authenticator then uses authentication actions to trigger BankID as the second factor authenticator.

Run the Example

You can run the end-to-end flow on a local computer. To do so, close the GitHub repository link from the top of this page. Follow the README instructions to understand prerequisites and run the following steps, after which you can run the full end-to-end flow.

  • Run a local Docker deployment that hosts the example MCP server, the Portfolio API and the Curity Identity Server.
  • Use the ngrok tool to expose local MCP server and OAuth endpoints to the internet.
  • Configure ChatGPT with an MCP server URL such as https://1b3889fd3a66.ngrok-free.app/mcp.
  • Follow the BankID Login instructions to install the app and create a test account.

Once the deployment is up and running, follow the README instructions to run the Admin UI and inspect the authentication and OAuth client settings. The READMEs also explain more about ways to view MCP and HAAPI messages, or run the MCP server in development mode.

Adapt the Example

The example demonstrates a generic design pattern in terms of a concrete use case, where ChatGPT is the AI agent and BankID is the step-up authenticator. You could follow the same pattern with other AI agents and step-up authentication methods.

Summary

AI agents and MCP clients can operate as full-fledged user agents and run frontend web forms. These forms can provide custom user experiences within the AI agent's frontend environment. The MCP client must initiate user authentication according to the MCP authorization specification, and use the system browser.

When you use the Curity Identity Server to secure MCP servers, you can use API-driven flows to manage step-up authentication and high-privilege operations. As a result, you optimize both security and user experience.

Newsletter

Join our Newsletter

Get the latest on identity management, API Security and authentication straight to your inbox.

Newsletter

Start Free Trial

Try the Curity Identity Server for Free. Get up and running in 10 minutes.

Start Free Trial