NetSuite OAuth 2.0 client credentials (M2M): the TBA replacement, step by step

Published on
-
10 mins read
Authors

If you build a new server-to-server NetSuite integration today, it should use the OAuth 2.0 client credentials flow, which NetSuite calls machine-to-machine (M2M). From NetSuite 2027.1 you cannot create new integrations on token-based authentication (TBA), so M2M is the default for every new backend, cron job, and middleware.

M2M replaces four static secrets with a certificate. Your service signs a short JWT with its private key, exchanges it for an access token that lasts 60 minutes, and repeats when it expires. No user logs in, and there is no refresh token.

This guide walks through the full setup and a dependency-free Node.js client.

In this article:

Prerequisites

  • Administrator access to a NetSuite sandbox
  • OpenSSL, to generate the key pair
  • Node.js 18 or later

Pick the right auth method

NetSuite has three modern options. Answer two questions to see which one fits.

Which auth method should I use?

Who calls NetSuite?

How the M2M flow works

OAuth 2.0 client credentials in NetSuite

Step 1 / 7
Your serviceholds private keyToken endpoint/auth/oauth2/v1/tokenNetSuite APIREST / SuiteQL / RESTlet1Build JWT2Sign with private key3POST client_assertion4Verify signature + mapping5access_token (3600 s)6Authorization: Bearer ...7200 OK

1. Build JWT

Header: alg PS256, typ JWT, kid = certificate ID. Payload: iss = client ID, scope, aud = token URL, iat, exp (at most 60 minutes later).

Click an arrow to jump to that step
The private key never leaves your service. NetSuite only stores the public certificate.

Step 1: Enable OAuth 2.0 and prepare the role

  1. Go to Setup > Company > Enable Features > SuiteCloud. Under Manage Authentication, check OAuth 2.0. Also check REST Web Services.
  2. Edit your integration role (not Administrator). On the Permissions > Setup subtab, add:
    • Log in using OAuth 2.0 Access Tokens: Full
    • REST Web Services: Full
  3. Add the record permissions the integration needs, and nothing more.

Step 2: Create the integration record

Go to Setup > Integration > Manage Integrations > New:

  1. Name it and set State to Enabled.
  2. Under OAuth 2.0, check Client Credentials (Machine to Machine) Grant.
  3. Under Scope, check REST Web Services, and RESTlets if you call RESTlets.
  4. Clear Token-Based Authentication and Authorization Code Grant unless this record really needs them.
  5. Save, and copy the Client ID (the consumer key). M2M does not use the client secret.

Step 3: Generate a certificate

NetSuite accepts RSA keys for the RSA-PSS scheme (PS256, PS384, PS512) and EC keys (ES256, ES384, ES512). An RSA 4096 key is a safe default:

openssl req -new -x509 -newkey rsa:4096 -sha256 -nodes \
-keyout netsuite-m2m-private.pem \
-out netsuite-m2m-public.pem \
-days 730 \
-subj "/CN=shopify-sync/O=Your Company"

You upload netsuite-m2m-public.pem to NetSuite. The private key goes into your secret manager and nowhere else.

Certificates expire quietly

When the certificate expires, token requests fail and nothing else warns you. Record the expiry date in your monitoring, and rotate before it. You can map a second certificate to the same integration and role, deploy the new key, then revoke the old mapping.

Step 4: Map the certificate

Go to Setup > Integration > Manage Authentication > OAuth 2.0 Client Credentials (M2M) Setup, and click Create New:

  • Entity: the integration employee
  • Role: the integration role from Step 1
  • Application: the integration record from Step 2
  • Certificate: upload the public PEM

Save, and copy the Certificate ID. It becomes the kid in the JWT header.

The JWT aud claim and the token URL are the same value. Type your account ID to get it:

Account ID → endpoints

Type your account ID the way it shows in the browser address bar or in Company Information.

TBA realm (uppercase, _)1234567_SB1
Hostname ID (lowercase, -)1234567-sb1
Authorize URLhttps://1234567-sb1.app.netsuite.com/app/login/oauth2/authorize.nl
Token URL (aud)https://1234567-sb1.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token
REST record APIhttps://1234567-sb1.suitetalk.api.netsuite.com/services/rest/record/v1
SuiteQL endpointhttps://1234567-sb1.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql
RESTlet domainhttps://1234567-sb1.restlets.api.netsuite.com/app/site/hosting/restlet.nl

Step 5: Get an access token in Node.js

You do not need a JWT library. Node's crypto.sign does RSA-PSS:

netsuite-m2m.js
import crypto from 'node:crypto'
const b64url = (input) => Buffer.from(input).toString('base64url')
export function createTokenClient({ accountId, clientId, certificateId, privateKeyPem, scope = ['rest_webservices'] }) {
const host = accountId.replace(/_/g, '-').toLowerCase()
const tokenUrl = `https://${host}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token`
let cached = null
function clientAssertion() {
const now = Math.floor(Date.now() / 1000)
const header = { alg: 'PS256', typ: 'JWT', kid: certificateId }
const payload = { iss: clientId, scope, aud: tokenUrl, iat: now, exp: now + 3600 }
const unsigned = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`
const signature = crypto.sign('sha256', Buffer.from(unsigned), {
key: privateKeyPem,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32,
})
return `${unsigned}.${signature.toString('base64url')}`
}
return async function getAccessToken() {
// Refresh 2 minutes early so in-flight requests never carry an expired token
if (cached && cached.expiresAt - 120_000 > Date.now()) return cached.token
const res = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: clientAssertion(),
}),
})
if (!res.ok) throw new Error(`Token request failed: ${res.status} ${await res.text()}`)
const body = await res.json()
cached = { token: body.access_token, expiresAt: Date.now() + Number(body.expires_in) * 1000 }
return cached.token
}
}

Use it like any bearer token:

run-suiteql.js
import fs from 'node:fs'
import { createTokenClient } from './netsuite-m2m.js'
const getAccessToken = createTokenClient({
accountId: process.env.NS_ACCOUNT_ID,
clientId: process.env.NS_CLIENT_ID,
certificateId: process.env.NS_CERT_ID,
privateKeyPem: fs.readFileSync(process.env.NS_PRIVATE_KEY_PATH, 'utf8'),
})
const host = process.env.NS_ACCOUNT_ID.replace(/_/g, '-').toLowerCase()
const res = await fetch(`https://${host}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql?limit=5`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAccessToken()}`,
'Content-Type': 'application/json',
Prefer: 'transient',
},
body: JSON.stringify({ q: 'SELECT id, entityid, email FROM customer ORDER BY id' }),
})
console.log(await res.json())

One token per process, not per request

Each token request counts against your account's request limits. Cache the token in memory, as above. If you run many workers, share the token through Redis or your platform's secret cache instead of having every worker sign its own JWT.

Common errors

SymptomUsual cause
invalid_client on the token requestWrong kid, iss, or aud; or the certificate is not mapped to this app
Token request rejected, IDs look rightexp more than 60 minutes after iat, server clock drift, or RS256 used
Token works, API call returns 401Role lacks Log in using OAuth 2.0 Access Tokens or REST Web Services
Token works, API call returns 403Scope does not include restlets, or the role lacks the record permission
Sandbox works, production failsM2M mapping and certificate were created in the sandbox only. Map them again.

Migrating from TBA

You do not need a big-bang cutover. A safe sequence per integration:

  1. Inventory. List integration records with TBA checked, and who owns each.
  2. Add M2M to the same integration record. Check Client Credentials next to TBA, and map a certificate to the same entity and role.
  3. Ship a dual-auth client. Read an NS_AUTH_MODE flag and pick TBA or M2M per call. Deploy with TBA still on.
  4. Flip the flag in a sandbox, then in production. Watch the Login Audit Trail and your error rate for a few days.
  5. Revoke the TBA tokens and clear TBA on the integration record.

Note

Existing TBA integrations keep working after 2027.1, but Oracle tentatively plans the final end of support for TBA in 2028.2. Starting now gives you more than a year to move each integration on your own schedule.

Conclusion

M2M looks harder than TBA at first, because it needs a certificate and a mapping. In return you get short-lived tokens, no secrets that live forever, and a request signature you compute once per hour instead of once per request.

If you still maintain TBA code, the companion post on setting up and debugging TBA explains the signing details. If your next consumer is an AI assistant rather than a service, read connecting Claude to NetSuite with the AI Connector Service.