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
| authType | Name | Description | Required headers |
|---|---|---|---|
none | No authentication | Public API, passed through directly | - |
app_key | Simple authentication | X-AppKey compared against the API's authorized app list | X-AppKey |
app_secret | Signature authentication | AppKey + timestamp + nonce + HMAC signature, tamper-proof and replay-proof | X-AppKey, X-Timestamp, X-Nonce, X-Sign, optional X-Algorithm |
oauth2 | OAuth2 client credentials | Exchanges AppKey/AppSecret for a short-lived access_token and calls with a Bearer token | Authorization: Bearer <access_token> |
jwt | Enterprise IdP JWT | The enterprise IdP issues the token; the platform only verifies it and identifies the app by subject binding | Authorization: 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
- Create a credential in Account center → App credentials to obtain the AppKey and AppSecret;
- Authorize the APIs you need to that credential (approved subscription or authorization by the API owner), or use a project-scoped credential;
- Make sure the API authentication method is "OAuth2 client credentials" (API editor → 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
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | Must be client_credentials |
client_id | Yes | The app's AppKey, up to 128 characters |
client_secret | Yes | The app's AppSecret, up to 512 characters |
scope | No | Reserved; 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)
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"
}
| Field | Description |
|---|---|
access_token | Access token, valid for REST and MCP |
token_type | Always Bearer |
expires_in | Validity period in seconds, 1800 (30 minutes) by default |
scope | App 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": "客户端凭证校验失败"
}
| HTTP | error | Meaning |
|---|---|---|
| 400 | invalid_request | Missing grant_type / client_id / client_secret, credentials passed in the URL query, or a malformed Basic header |
| 400 | unsupported_grant_type | grant_type is not client_credentials |
| 401 | invalid_client | Credentials do not match, the app is disabled, or the source IP is not in the app whitelist |
| 429 | invalid_client | Too many failed credential validations (5 per 10 minutes by default), temporarily locked |
| 500 | server_error | Server-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):
| code | Meaning | Common cause |
|---|---|---|
40024 | Authorization header must not be empty | Bearer token missing, or the Bearer prefix misspelled |
40025 | Token invalid or expired | Token expired, tampered with, issued by another environment, or the app was deleted |
40026 | Token revoked, please obtain a new one | The token was revoked (server-side session deleted) |
40002 | Authentication failed, app has no permission for this API | The token is valid but the API is not authorized to the app |
40018 | The IP is not within the authorized range | The app has an IP whitelist and the current source IP is not in it |
40019 | App disabled | The app has been disabled |
40001 | API not found or offline | The 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: Beareris present the request is verified as OAuth2; otherwise it falls back toX-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; Authorizationis a sensitive header and is not forwarded to the target API.
Token lifecycle
| Rule | Description |
|---|---|
| Validity | 1800 seconds by default; configured on the platform within the range of 60 seconds to 24 hours |
| Renewal | There is no refresh_token; call /oauth2/token again after expiry. Clients should refresh at expires_in - 30 seconds |
| Caching | Cache 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 revocation | Disabling the app invalidates tokens immediately; an administrator can also clear the token's server-side session for instant revocation |
| Scope | Project-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 rotation | After 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
| Aspect | app_secret signature | oauth2 client credentials |
|---|---|---|
| AppSecret sent on every request | No (only used to compute the signature) | No (transmitted once when exchanging the token) |
| Headers | X-AppKey, X-Timestamp, X-Nonce, X-Sign | Authorization: Bearer <access_token> |
| Replay protection | Timestamp window + nonce | Short-lived token + server-side session |
| Immediate revocation | Disable the app | Disabling the app takes effect immediately; the server-side session can also be cleared |
| Client implementation effort | Implement SM3 / HMAC-SM3 | Standard OAuth2, available in every language's SDK |
| Best fit | Internal systems with SM algorithm support | Standard OAuth2 clients, agents, backend services |
Troubleshooting
| Symptom | What to check |
|---|---|
400 invalid_request | Is 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_type | grant_type must be client_credentials |
401 invalid_client | Do 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_client | Failure count exceeded the limit; wait for the lock window (10 minutes by default) instead of retrying blindly |
500 server_error | Platform-side failure; retry later and contact the administrator if it persists |
Open API returns 40024 | The request has no Authorization: Bearer <token>, or the Bearer prefix is missing |
Open API returns 40025 | The token expired or is invalid; exchange a new one and make sure it was issued by the same environment |
Open API returns 40026 | The token was revoked; exchange a new one |
Open API returns 40002 | The token is valid but the API is not authorized to the app; add a subscription or authorization |
Open API returns 40018 | The 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
- Keep the AppSecret on the server only; never ship it in browsers, app packages, frontend configuration or code repositories;
- Use HTTPS end to end: token requests and the Bearer header both carry credentials, and only TLS provides confidentiality;
- Cache and reuse tokens, refreshing at
expires_in - 30seconds instead of exchanging on every request; - Restrict sources with IP whitelists: once the app has
ipsconfigured, both token issuance and API calls validate the source; - 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;
- Rotate the AppSecret and immediately exchange a new token, notifying callers in time;
- Do not retry blindly:
invalid_clientis 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