Skip to main content
Resend implements OAuth 2.0 and 2.1, including Proof Key for Code Exchange (PKCE) for authorization code exchanges. We also support Dynamic Client Registration (DCR), which lets a client register itself at runtime via POST /oauth/register. Use DCR when a client, such as a MCP host, can’t know its deployment details in advance. We do not yet support confidential clients, so all clients are public, and PKCE is required on every authorization code exchange. Because we do not offer self-service verification or domain-ownership check yet, remote third-party clients should still be pre-registered rather than dynamically registered, but you can register it with the same register endpoint. Our dashboard hosts the login and consent screen. Your client only needs to open the authorization URL in a browser and handle the callback. You don’t build any consent UI yourself. Before getting to the integration, decide between registering the client dynamically or registering it beforehand and reusing a fixed client_id. Dynamic registration is for clients that can’t predict their own deployment details ahead of time, like an MCP server. The standard case is registering beforehand, and the rest of this guide assumes that path. If you’re registering beforehand, ask us to mark the client manual_verified. That gets it a verified badge on the consent screen. It’s the only effect it has today. It doesn’t change scopes, rate limits, or anything else.

Scopes

Use the smallest scope that works for your integration:
  • emails:send is enough for send-only routes, such as POST /emails, POST /email, POST /emails/sending, POST /email/sending, and POST /broadcasts/:broadcastId/send.
  • full_access is required for other API routes.
If a dynamically registered client omits scope, we register it with every supported scope. Pass scope explicitly during registration and authorization instead of relying on that default.

Request encoding and resource

Dynamic client registration uses JSON. The token and revocation endpoints accept both JSON and application/x-www-form-urlencoded, but prefer form encoding for /oauth/token and /oauth/revoke, since that’s what most OAuth libraries send by default. We do not support RFC 8707 resource indicators yet. Don’t send or rely on resource in authorization or token requests. It’s accepted but ignored.

Generating PKCE values and state

Before starting the authorization request, generate three values:
  • code_verifier: a high-entropy random string kept only by the client.
  • code_challenge: the base64url-encoded SHA-256 hash of the code_verifier.
  • state: a high-entropy random string used to bind the callback to the request that started the flow.
The code_verifier is sent only during the token exchange. The code_challenge is sent during authorization. The state is sent during authorization and must come back unchanged on the callback.
import { createHash, randomBytes } from 'node:crypto';

function base64url(input) {
  return Buffer.from(input).toString('base64url');
}

const codeVerifier = base64url(randomBytes(64));
const codeChallenge = base64url(
  createHash('sha256').update(codeVerifier).digest(),
);
const state = base64url(randomBytes(24));
For a remote client, store state and codeVerifier server-side before redirecting the user to us. For a local client, keep them in memory while the temporary callback server is running.

Pre-registered remote client

A remote client should use an HTTPS redirect URI owned by the app, for example https://example.com/oauth/callback. For remote apps, you can still use POST /oauth/register manually while we don’t have a central place in the app to create clients.
1

Load the pre-issued client_id

2

Generate PKCE and state

code_verifier, code_challenge, and state. See above.
3

Store state and code_verifier server-side

Tie them to the user’s session.
4

Redirect the user's browser to /oauth/authorize

5

Handle the callback on the app's backend

6

Reject invalid callbacks

A missing code, mismatched or missing state, or an error query parameter should all be treated as failures.
7

Exchange the code server-side

Use the original code_verifier.
8

Store the refresh token securely

9

Serialize refreshes

Atomically replace the stored refresh token after every refresh.
Example authorization URL:
GET /oauth/authorize?client_id=550e8400-e29b-41d4-a716-446655440000&response_type=code&redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback&scope=emails%3Asend&state=STATE_VALUE&code_challenge=CODE_CHALLENGE_VALUE&code_challenge_method=S256 HTTP/1.1
Host: api.resend.com
Example code exchange:
curl -X POST 'https://api.resend.com/oauth/token' \
     -H 'Content-Type: application/x-www-form-urlencoded' \
     -d 'grant_type=authorization_code&client_id=550e8400-e29b-41d4-a716-446655440000&code=AUTHORIZATION_CODE&redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback&code_verifier=CODE_VERIFIER_VALUE'

Local client

A local client must use a loopback redirect URI. Prefer 127.0.0.1 with a random local port, for example http://127.0.0.1:49152/oauth/callback. Registered loopback redirect URIs allow port variance. The host, path, and query string must still match. For example, a client can register http://127.0.0.1/oauth/callback and later authorize with http://127.0.0.1:49152/oauth/callback.
1

Bind a temporary callback server

Pick a random high port and bind it to 127.0.0.1 or [::1], never 0.0.0.0.
2

Generate PKCE and state

code_verifier, code_challenge, and state. See above.
3

Register the client

Dynamically register with a loopback redirect URI and the minimum required scope. See Register Client.
4

Open the authorize URL in the user's browser

Don’t prefetch /oauth/authorize from your process and follow the redirect yourself. The user needs to see the consent screen.
5

Let the browser follow the redirect to the dashboard consent screen

6

Handle exactly one callback, then close the server

7

Reject invalid callbacks

A missing code, mismatched or missing state, or an error query parameter should all be treated as failures.
8

Exchange the code

Use the original code_verifier. See Token.
9

Store the newest refresh token after every token response

Close the loopback server after success or timeout.

Dynamic client registration

Pass scope explicitly. If DCR omits scope, we register the client with every supported scope by default.
curl -X POST 'https://api.resend.com/oauth/register' \
     -H 'Content-Type: application/json' \
     -d $'{
  "client_name": "Example Local OAuth Client",
  "redirect_uris": ["http://127.0.0.1/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "emails:send"
}'
The response returns a UUID client_id:
{
  "client_id": "550e8400-e29b-41d4-a716-446655440000",
  "client_id_issued_at": 1750000000,
  "client_name": "Example Local OAuth Client",
  "redirect_uris": ["http://127.0.0.1/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "emails:send"
}

Authorization request

Do not have a backend or CLI process prefetch /oauth/authorize and then open the returned dashboard URL. Instead, open the authorization URL in the user’s browser and let the browser follow the redirect to the dashboard.
const authorizationUrl = new URL('https://api.resend.com/oauth/authorize');
authorizationUrl.search = new URLSearchParams({
  client_id: '550e8400-e29b-41d4-a716-446655440000',
  response_type: 'code',
  redirect_uri: 'http://127.0.0.1:49152/oauth/callback',
  scope: 'emails:send',
  state,
  code_challenge: codeChallenge,
  code_challenge_method: 'S256',
}).toString();

openBrowser(authorizationUrl.toString());
After approval, we redirect back to the exact redirect_uri used in the authorization request:
GET /oauth/callback?code=AUTHORIZATION_CODE&state=STATE_VALUE HTTP/1.1
Host: 127.0.0.1:49152
Verify that the returned state matches the original state before exchanging the code.

Authorization code exchange

The redirect_uri in the token request must match the redirect_uri from the authorization request.
curl -X POST 'https://api.resend.com/oauth/token' \
     -H 'Content-Type: application/x-www-form-urlencoded' \
     -d 'grant_type=authorization_code&client_id=550e8400-e29b-41d4-a716-446655440000&code=AUTHORIZATION_CODE&redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Foauth%2Fcallback&code_verifier=CODE_VERIFIER_VALUE'
The response includes a JWT access token and an opaque refresh token:
{
  "access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoX2tleSIsInR5cCI6ImF0K2p3dCJ9...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "JcL7aYfE7S9h3L4qv0o2e1w8m6n5b3x9RkP2tD4uV6Q",
  "scope": "emails:send"
}

Refresh token exchange

Refresh tokens rotate on every successful refresh. Serialize refresh operations for a grant, and store the new refresh token atomically with the rest of the response. If refresh succeeds but the new refresh token isn’t persisted, you’ll need to reauthorize. Retrying an old token from multiple workers can revoke the whole grant. See Token for the reuse-detection details.
curl -X POST 'https://api.resend.com/oauth/token' \
     -H 'Content-Type: application/x-www-form-urlencoded' \
     -d 'grant_type=refresh_token&client_id=550e8400-e29b-41d4-a716-446655440000&refresh_token=JcL7aYfE7S9h3L4qv0o2e1w8m6n5b3x9RkP2tD4uV6Q'

Revoking access

To disconnect a client, revoke the refresh token:
curl -X POST 'https://api.resend.com/oauth/revoke' \
     -H 'Content-Type: application/x-www-form-urlencoded' \
     -d 'client_id=550e8400-e29b-41d4-a716-446655440000&token=JcL7aYfE7S9h3L4qv0o2e1w8m6n5b3x9RkP2tD4uV6Q&token_type_hint=refresh_token'
Access tokens are JWTs and can’t be revoked individually. Revoking the refresh token revokes the grant. See Revoke Token.