How to Authenticate an OpenAI Plugin: OAuth PKCE and Declarative Policies Explained

OpenAI plugins use a declarative authentication model defined in the marketplace policy and implemented via OAuth 2.0 PKCE flows, with tokens stored securely by the plugin backend.

Authenticating an OpenAI plugin requires understanding the two-tier declarative model used in the openai/plugins repository. The system separates authentication timing policies from implementation details, allowing developers to specify when authorization occurs while implementing standard OAuth 2.0 PKCE flows for credential exchange.

Understanding the Declarative Authentication Model

The authentication architecture relies on two distinct configuration layers that work together to control when and how users authorize third-party services.

Marketplace Authentication Policy in marketplace.json

The .agents/plugins/marketplace.json file controls when authentication triggers through the policy.authentication field. This field accepts two values:

  • ON_INSTALL – Authentication occurs once immediately after installation, with tokens persisted for subsequent use.
  • ON_USE – Authentication occurs each time a user invokes a skill requiring credentials, suitable for short-lived authorization contexts.

If omitted, the default value is ON_INSTALL according to the plugin specification in .agents/skills/plugin-creator/references/plugin-json-spec.md (lines 54-56).

Example from the Zoom plugin entry:

{
  "name": "zoom",
  "source": {
    "source": "local",
    "path": "./plugins/zoom"
  },
  "policy": {
    "installation": "AVAILABLE",
    "authentication": "ON_INSTALL"
  },
  "category": "Communication"
}

Plugin Manifest Structure

While the plugin.json manifest located at plugins/<name>/.codex-plugin/plugin.json does not contain authentication settings directly, it defines the scopes and metadata required for the OAuth flow. The manifest specifies capabilities, website URLs, and privacy policies that the authorization interface displays.

Example from plugins/zoom/.codex-plugin/plugin.json:

{
  "name": "zoom",
  "version": "1.0.2",
  "keywords": [ "zoom", "oauth", "meeting-sdk" ],
  "interface": {
    "capabilities": [ "Interactive", "Read", "Write" ],
    "websiteURL": "https://developers.zoom.us/",
    "privacyPolicyURL": "https://www.zoom.com/en/trust/privacy/",
    "termsOfServiceURL": "https://www.zoom.com/en/trust/terms/"
  }
}

Implementing OAuth 2.0 PKCE Flows

OpenAI plugins implement OAuth 2.0 with Proof Key for Code Exchange (PKCE) to securely obtain access tokens without exposing client secrets in frontend code.

Generating PKCE Challenges

The PKCE flow begins with generating a cryptographically random verifier and challenge. In plugins/zoom/skills/zoom-apps-sdk/references/oauth.md (lines 17-28), the implementation uses Node.js crypto:

const crypto = require('crypto');

// Generate PKCE pair
const verifier = crypto.randomBytes(32).toString('hex');
const challenge = crypto.createHash('sha256')
  .update(verifier)
  .digest('base64url');

Web-Based Authorization Handler

For server-side implementations, the plugin handles the redirect from the OAuth provider and exchanges the authorization code for tokens. The reference implementation in plugins/zoom/skills/zoom-apps-sdk/references/oauth.md (lines 55-92) demonstrates this pattern:

app.get('/auth', async (req, res) => {
  const { code, state } = req.query;
  // Validate CSRF state …
  const tokenResponse = await axios.post('https://zoom.us/oauth/token', null, {
    params: {
      grant_type: 'authorization_code',
      code,
      redirect_uri: process.env.ZOOM_APP_REDIRECT_URI,
      code_verifier: req.session.codeVerifier
    },
    headers: {
      Authorization: 'Basic ' + Buffer.from(
        `${process.env.ZOOM_APP_CLIENT_ID}:${process.env.ZOOM_APP_CLIENT_SECRET}`
      ).toString('base64')
    }
  });
  // Store tokens, obtain deeplink, redirect user …
});

In-Client OAuth Flow

For enhanced user experience, plugins can use in-client OAuth where the authorization happens within the host application. As shown in plugins/zoom/skills/zoom-apps-sdk/references/oauth.md (lines 99-112):

// Front‑end
const { codeChallenge, state } = await fetch('/api/auth/challenge').then(r => r.json());

zoomSdk.addEventListener('onAuthorized', async (event) => {
  await fetch('/api/auth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code: event.code, state: event.state })
  });
});

await zoomSdk.authorize({ codeChallenge, state });

Token Storage and Refresh Strategies

After obtaining tokens, plugins must implement secure storage and handle token expiration.

Secure Token Storage

The reference implementation outlines multiple storage strategies depending on deployment architecture. For production multi-instance deployments, Redis provides shared state across servers, while single-server applications can use encrypted session cookies. The Zoom plugin reference (lines 91-95) suggests:

// Store in Redis for distributed systems
await redis.set(`zoom:tokens:${sessionId}`, JSON.stringify({
  access_token: tokenResponse.data.access_token,
  refresh_token: tokenResponse.data.refresh_token,
  expires_at: Date.now() + tokenResponse.data.expires_in * 1000
}));

Refreshing Single-Use Tokens

According to plugins/zoom/skills/zoom-apps-sdk/references/oauth.md (lines 64-66), refresh tokens are single-use only. After each refresh request, the provider issues a new refresh token alongside the access token. Implementations must update their storage to replace the old refresh token with the new one to prevent authentication failures.

Complete Implementation Example

Putting together the PKCE generation, challenge endpoint, and token exchange creates a complete authentication flow:

const crypto = require('crypto');
const axios = require('axios');

// PKCE generation utility
function generatePKCE() {
  const verifier = crypto.randomBytes(32).toString('hex');
  const challenge = crypto.createHash('sha256')
    .update(verifier)
    .digest('base64url');
  return { verifier, challenge };
}

// Challenge endpoint
app.get('/api/auth/challenge', (req, res) => {
  const { challenge, verifier } = generatePKCE();
  req.session.codeVerifier = verifier;
  req.session.state = crypto.randomBytes(16).toString('hex');
  
  res.json({
    codeChallenge: challenge,
    state: req.session.state,
    authUrl: `https://zoom.us/oauth/authorize?client_id=${process.env.ZOOM_APP_CLIENT_ID}` +
             `&response_type=code&redirect_uri=${process.env.ZOOM_APP_REDIRECT_URI}` +
             `&code_challenge=${challenge}&code_challenge_method=S256&state=${req.session.state}`
  });
});

// Token exchange
app.post('/api/auth/token', async (req, res) => {
  const { code, state } = req.body;
  if (state !== req.session.state) return res.status(403).send('Invalid state');
  
  const tokenResponse = await axios.post('https://zoom.us/oauth/token', null, {
    params: {
      grant_type: 'authorization_code',
      code,
      redirect_uri: process.env.ZOOM_APP_REDIRECT_URI,
      code_verifier: req.session.codeVerifier
    },
    headers: {
      Authorization: 'Basic ' + Buffer.from(
        `${process.env.ZOOM_APP_CLIENT_ID}:${process.env.ZOOM_APP_CLIENT_SECRET}`
      ).toString('base64')
    }
  });
  
  // Secure storage implementation here
  res.json({ ok: true });
});

Summary

Authenticating an OpenAI plugin requires coordination between declarative configuration and OAuth implementation:

  • The policy.authentication field in .agents/plugins/marketplace.json determines whether authentication occurs ON_INSTALL or ON_USE, defaulting to ON_INSTALL if unspecified.
  • The plugin.json manifest defines metadata and capabilities but does not contain authentication credentials.
  • OAuth 2.0 PKCE flows require generating a cryptographically secure verifier and challenge, exchanging the authorization code for tokens, and storing refresh tokens securely.
  • Refresh tokens are single-use and must be replaced in storage after each refresh operation.
  • Production deployments should use distributed storage like Redis for token persistence across multiple server instances.

Frequently Asked Questions

What is the difference between ON_INSTALL and ON_USE authentication policies?

The ON_INSTALL policy triggers authentication immediately after the user installs the plugin, storing tokens for persistent use across all subsequent skill invocations. The ON_USE policy defers authentication until the first time a user executes a skill requiring credentials, which is useful for short-lived authorizations or when credentials should not persist between sessions.

Where should I store OAuth tokens in an OpenAI plugin?

Store tokens in a secure, encrypted backend storage system appropriate for your deployment architecture. For multi-instance production environments, use Redis or a database with encryption at rest. For single-server deployments, encrypted session cookies suffice. Never store tokens in frontend code or local browser storage.

Does the plugin.json file contain OAuth client secrets?

No, the plugin.json manifest located at plugins/<name>/.codex-plugin/plugin.json contains only metadata such as capabilities, website URLs, and privacy policies. It does not contain authentication settings or secrets. Client secrets and access tokens belong exclusively in your backend implementation and secure storage.

How do I handle token expiration in OpenAI plugins?

Implement token refresh logic that detects expired access tokens and uses the stored refresh token to obtain new credentials. According to the Zoom plugin reference in plugins/zoom/skills/zoom-apps-sdk/references/oauth.md (lines 64-66), remember that refresh tokens are single-use—each refresh request returns a new refresh token that must replace the old one in your storage system.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →