Skip to main content

OAuth2 client credentials authentication

Applies to: open APIs with authType = oauth2 (OAuth2 Client Credentials) Credentials: the AppKey / AppSecret of an API Credential on the platform, client_id = AppKey, client_secret = AppSecret Typical scenarios: server-to-server calls (backend services, scheduled jobs, agents, data integration)

Authentication overview​

authTypeNameDescriptionRequired headers
noneNo authenticationPublic API, passed through directly-
app_keySimple authenticationX-AppKey compared against the API's authorized app listX-AppKey
app_secretSignature authenticationAppKey + timestamp + nonce + HMAC signature, tamper-proof and replay-proofX-AppKey, X-Timestamp, X-Nonce, X-Sign, optional X-Algorithm
oauth2OAuth2 client credentialsExchanges AppKey/AppSecret for a short-lived access_token and calls with a Bearer tokenAuthorization: Bearer <access_token>
jwtEnterprise IdP JWTThe enterprise IdP issues the token; the platform only verifies it and identifies the app by subject bindingAuthorization: Bearer <enterprise_token>
  • With OAuth2 the AppSecret is transmitted only once, when exchanging for a token; every call afterwards carries just the short-lived token, which suits standard OAuth2 clients that cannot easily implement the signature algorithm;
  • The token endpoint is on the same origin as the open APIs: {environment access address}/oauth2/token;
  • The same access_token works for both REST open APIs and the MCP entry.

Prerequisites​

  1. Create a credential in Account center → App credentials to obtain the AppKey and AppSecret;
  2. Authorize the APIs you need to that credential (approved subscription or authorization by the API owner), or use a project-scoped credential;
  3. Make sure the API authentication method is "OAuth2 client credentials" (API editor → authentication).
Relationship with signature authentication

OAuth2 credentials reuse the same app credential as signature authentication; no extra key is required. After switching the API authentication method, callers stop computing X-Sign and instead exchange and carry a token; all other parameters and response structures stay the same.

Exchanging for an access_token​

Endpoint​

POST {environment access address}/oauth2/token
Content-Type: application/x-www-form-urlencoded

Request parameters​

ParameterRequiredDescription
grant_typeYesMust be client_credentials
client_idYesThe app's AppKey, up to 128 characters
client_secretYesThe app's AppSecret, up to 512 characters
scopeNoReserved; the server returns the app's scope instead of accepting a client value

The standard Basic authentication header is also supported, in which case the body no longer needs client_id / client_secret:

Authorization: Basic base64(client_id:client_secret)
Credentials must go in the form body or the Basic header

Passing client_id / client_secret as URL query parameters is rejected outright (HTTP 400 invalid_request) to keep credentials out of access logs and proxy logs.

Success response​

HTTP 200:

{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJzMjU2LThhY2MxMjM0In0...",
"token_type": "Bearer",
"expires_in": 1800,
"scope": "api"
}
FieldDescription
access_tokenAccess token, valid for REST and MCP
token_typeAlways Bearer
expires_inValidity period in seconds, 1800 (30 minutes) by default
scopeApp scope: api (per-API authorization) / project (project level)

Error response​

The token endpoint returns an OAuth2 standard error body, not the platform {code, msg} wrapper:

{
"error": "invalid_client",
"error_description": "客户端凭证校验失败"
}
HTTPerrorMeaning
400invalid_requestMissing grant_type / client_id / client_secret, credentials passed in the URL query, or a malformed Basic header
400unsupported_grant_typegrant_type is not client_credentials
401invalid_clientCredentials do not match, the app is disabled, or the source IP is not in the app whitelist
429invalid_clientToo many failed credential validations (5 per 10 minutes by default), temporarily locked
500server_errorServer-side failure (such as a session write error)

Calling open APIs​

When the API authentication method is oauth2, callers only carry the token and no longer compute a signature:

curl -X GET 'https://api.example.com/open/api/demo?page=1' \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIs...'

Authentication failures return HTTP 200 with a business code (consistent with the platform's other authentication methods):

codeMeaningCommon cause
40024Authorization header must not be emptyBearer token missing, or the Bearer prefix misspelled
40025Token invalid or expiredToken expired, tampered with, issued by another environment, or the app was deleted
40026Token revoked, please obtain a new oneThe token was revoked (server-side session deleted)
40002Authentication failed, app has no permission for this APIThe token is valid but the API is not authorized to the app
40018The IP is not within the authorized rangeThe app has an IP whitelist and the current source IP is not in it
40019App disabledThe app has been disabled
40001API not found or offlineThe path does not match the configuration

Calling MCP​

The MCP entry also accepts a Bearer token:

POST {environment access address}/mcp/{appCode}
Authorization: Bearer <access_token>
Content-Type: application/json
curl -X POST 'https://api.example.com/mcp/myApp' \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIs...' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
  • When Authorization: Bearer is present the request is verified as OAuth2; otherwise it falls back to X-AppKey (for existing clients);
  • After authentication the gateway still checks the app is enabled, MCP is enabled, the {appCode} in the path matches the app, and the source IP is in the app whitelist;
  • Authorization is a sensitive header and is not forwarded to the target API.

Token lifecycle​

RuleDescription
Validity1800 seconds by default; configured on the platform within the range of 60 seconds to 24 hours
RenewalThere is no refresh_token; call /oauth2/token again after expiry. Clients should refresh at expires_in - 30 seconds
CachingCache the token in the process and reuse it instead of exchanging on every request; with multiple instances, avoid sharing one token file and hitting expiry races
Immediate revocationDisabling the app invalidates tokens immediately; an administrator can also clear the token's server-side session for instant revocation
ScopeProject-scoped credentials (scope = project) can access every API in the project; API-scoped credentials (scope = api) only reach authorized APIs, and cross-project calls return 40002
Secret rotationAfter rotating the AppSecret, obtain a new token; old tokens remain valid until they expire

Examples​

cURL​

#!/usr/bin/env bash
set -euo pipefail

BASE_URL="https://api.example.com"
APP_KEY="ak_1234567890"
APP_SECRET="sk-test-1234567890abcdef"

# 1) Exchange for a token
TOKEN="$(curl -sS -X POST "${BASE_URL}/oauth2/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=${APP_KEY}" \
--data-urlencode "client_secret=${APP_SECRET}" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')"

# 2) Call the API
curl -sS "${BASE_URL}/open/api/demo?page=1" -H "Authorization: Bearer ${TOKEN}"

Java (JDK 11+ HttpClient)​

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;

public class OAuth2ClientDemo {

private static final String BASE_URL = "https://api.example.com";
private static final String APP_KEY = "ak_1234567890";
private static final String APP_SECRET = "sk-test-1234567890abcdef";

/** In-process cache: treat the token as expired 30 seconds early */
private static String cachedToken;
private static Instant cachedExpireAt = Instant.EPOCH;

public static void main(String[] args) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/open/api/demo?page=1"))
.header("Authorization", "Bearer " + token())
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
System.out.println(response.statusCode());
System.out.println(response.body());
}

/** Obtain an access_token, reusing the cached one while it is still valid */
private static synchronized String token() throws Exception {
if (cachedToken != null && Instant.now().isBefore(cachedExpireAt)) {
return cachedToken;
}
String form = "grant_type=client_credentials"
+ "&client_id=" + URLEncoder.encode(APP_KEY, StandardCharsets.UTF_8)
+ "&client_secret=" + URLEncoder.encode(APP_SECRET, StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/oauth2/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200) {
// The token endpoint returns the OAuth2 error body: {"error": "...", "error_description": "..."}
throw new IllegalStateException("token request failed: " + response.body());
}
// Use Jackson/Gson in production to parse the response
String body = response.body();
int index = body.indexOf("\"access_token\":\"") + "\"access_token\":\"".length();
cachedToken = body.substring(index, body.indexOf('"', index));
cachedExpireAt = Instant.now().plusSeconds(1770);
return cachedToken;
}
}

Python (requests)​

import time
import requests

BASE_URL = "https://api.example.com"
APP_KEY = "ak_1234567890"
APP_SECRET = "sk-test-1234567890abcdef"

_token = {"value": "", "expire_at": 0.0}


def fetch_token(force: bool = False) -> str:
"""Obtain an access_token, reusing the cached one and refreshing 30 seconds early."""
if not force and _token["value"] and time.time() < _token["expire_at"]:
return _token["value"]
resp = requests.post(
f"{BASE_URL}/oauth2/token",
data={
"grant_type": "client_credentials",
"client_id": APP_KEY,
"client_secret": APP_SECRET,
},
timeout=10,
)
resp.raise_for_status()
payload = resp.json()
_token["value"] = payload["access_token"]
_token["expire_at"] = time.time() + max(payload.get("expires_in", 1800) - 30, 10)
return _token["value"]


def call_api() -> requests.Response:
headers = {"Authorization": f"Bearer {fetch_token()}"}
resp = requests.get(f"{BASE_URL}/open/api/demo", params={"page": 1}, headers=headers, timeout=10)
# Token expired or revoked: 40025 / 40026, exchange again and retry once
if resp.status_code == 200 and resp.json().get("code") in (40025, 40026):
headers = {"Authorization": f"Bearer {fetch_token(force=True)}"}
resp = requests.get(f"{BASE_URL}/open/api/demo", params={"page": 1}, headers=headers, timeout=10)
return resp

Node.js​

const BASE_URL = 'https://api.example.com'
const APP_KEY = 'ak_1234567890'
const APP_SECRET = 'sk-test-1234567890abcdef'

let cached = { token: '', expireAt: 0 }

async function fetchToken(force = false) {
if (!force && cached.token && Date.now() < cached.expireAt) {
return cached.token
}
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: APP_KEY,
client_secret: APP_SECRET,
})
const res = await fetch(`${BASE_URL}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const text = await res.text()
if (!res.ok) {
throw new Error(`token request failed: ${res.status} ${text}`)
}
const json = JSON.parse(text)
cached = {
token: json.access_token,
// Treat the token as expired 30 seconds early
expireAt: Date.now() + Math.max((json.expires_in || 1800) - 30, 10) * 1000,
}
return cached.token
}

async function callApi() {
const token = await fetchToken()
const res = await fetch(`${BASE_URL}/open/api/demo?page=1`, {
headers: { Authorization: `Bearer ${token}` },
})
console.log(res.status, await res.text())
}

Go (net/http)​

package main

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)

const (
baseURL = "https://api.example.com"
appKey = "ak_1234567890"
appSecret = "sk-test-1234567890abcdef"
)

var (
mu sync.Mutex
token string
expireAt time.Time
)

// fetchToken obtains an access_token, reusing the cached one (treating it as expired 30 seconds early).
func fetchToken(force bool) (string, error) {
mu.Lock()
defer mu.Unlock()
if !force && token != "" && time.Now().Before(expireAt) {
return token, nil
}
form := url.Values{
"grant_type": {"client_credentials"},
"client_id": {appKey},
"client_secret": {appSecret},
}
resp, err := http.Post(baseURL+"/oauth2/token", "application/x-www-form-urlencoded",
strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
// The token endpoint returns the OAuth2 error body
return "", fmt.Errorf("token request failed: %d %s", resp.StatusCode, body)
}
var payload struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", err
}
token = payload.AccessToken
expireAt = time.Now().Add(time.Duration(payload.ExpiresIn-30) * time.Second)
return token, nil
}

func main() {
t, err := fetchToken(false)
if err != nil {
panic(err)
}
req, _ := http.NewRequest(http.MethodGet, baseURL+"/open/api/demo?page=1", nil)
req.Header.Set("Authorization", "Bearer "+t)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.StatusCode, string(body))
}

Comparison with signature authentication​

Aspectapp_secret signatureoauth2 client credentials
AppSecret sent on every requestNo (only used to compute the signature)No (transmitted once when exchanging the token)
HeadersX-AppKey, X-Timestamp, X-Nonce, X-SignAuthorization: Bearer <access_token>
Replay protectionTimestamp window + nonceShort-lived token + server-side session
Immediate revocationDisable the appDisabling the app takes effect immediately; the server-side session can also be cleared
Client implementation effortImplement SM3 / HMAC-SM3Standard OAuth2, available in every language's SDK
Best fitInternal systems with SM algorithm supportStandard OAuth2 clients, agents, backend services

Troubleshooting​

SymptomWhat to check
400 invalid_requestIs Content-Type application/x-www-form-urlencoded; are grant_type and client_id / client_secret all present; were the credentials put in the URL query
400 unsupported_grant_typegrant_type must be client_credentials
401 invalid_clientDo the AppKey/AppSecret belong to the same app and were they not reset; is the app enabled; is the source IP whitelisted; is client_id longer than 128 or client_secret longer than 512 characters
429 invalid_clientFailure count exceeded the limit; wait for the lock window (10 minutes by default) instead of retrying blindly
500 server_errorPlatform-side failure; retry later and contact the administrator if it persists
Open API returns 40024The request has no Authorization: Bearer <token>, or the Bearer prefix is missing
Open API returns 40025The token expired or is invalid; exchange a new one and make sure it was issued by the same environment
Open API returns 40026The token was revoked; exchange a new one
Open API returns 40002The token is valid but the API is not authorized to the app; add a subscription or authorization
Open API returns 40018The source IP is not in the app whitelist

Order of investigation: first confirm /oauth2/token returns an access_token, then call any authorized API and confirm 200, and finally check business parameters.

Security recommendations​

  1. Keep the AppSecret on the server only; never ship it in browsers, app packages, frontend configuration or code repositories;
  2. Use HTTPS end to end: token requests and the Bearer header both carry credentials, and only TLS provides confidentiality;
  3. Cache and reuse tokens, refreshing at expires_in - 30 seconds instead of exchanging on every request;
  4. Restrict sources with IP whitelists: once the app has ips configured, both token issuance and API calls validate the source;
  5. Least privilege: authorize only the APIs an API-scoped credential needs; a project-scoped credential is a project-wide pass and must be issued sparingly;
  6. Rotate the AppSecret and immediately exchange a new token, notifying callers in time;
  7. Do not retry blindly: invalid_client is usually a credential or configuration problem, and repeated failures trigger a temporary lock.

Quick reference​

1) Prepare: app credential AppKey / AppSecret; authorize the APIs to that app
2) Get a token: POST {environment address}/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=<AppKey>&client_secret=<AppSecret>
3) Response: { access_token, token_type: "Bearer", expires_in: 1800, scope }
4) REST call: Authorization: Bearer <access_token>
MCP call : POST /mcp/{appCode} + Authorization: Bearer <access_token>
5) Renewal: exchange again at expires_in - 30 seconds; expiry / revocation returns 40025 / 40026
6) Revocation: disabling the app takes effect immediately