- Public clients authenticate with PKCE alone. Register them with
token_endpoint_auth_method: "none"(the default). Use a public client when it can’t keep a secret, such as native apps, CLIs, and single-page apps. - Confidential clients additionally present a
client_secretat the token and revocation endpoints. Register them withtoken_endpoint_auth_method: "client_secret_basic"(orclient_secret_post) to have Resend issue the secret. Use a confidential client when it has a backend that can store the secret privately, such as a server-side web app.
Recommended implementation paths
Before getting to the integration, decide between registering the client dynamically or registering it beforehand and reusing a fixedclient_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.
Also decide whether the client is public or confidential. A client running entirely on the user’s machine, such as a native app, CLI, or single-page app, can’t hide a secret, so register it as public (none). A client with a server-side backend should register as confidential (client_secret_basic) and keep the issued client_secret out of any user-facing code. The pre-registered remote client section below covers the confidential path, and the local client section covers the public one.
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:sendis enough for send-only routes, such asPOST /emails,POST /email,POST /emails/sending,POST /email/sending, andPOST /broadcasts/:broadcastId/send.full_accessis required for other API routes.
scope, Resend registers 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.
Resend does 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 thecode_verifier.state: a high-entropy random string used to bind the callback to the request that started the flow.
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.
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 must use an HTTPS redirect URI owned by the app, for examplehttps://example.com/oauth/callback.
Because a remote client has a backend that can keep a secret, register it as confidential: pass token_endpoint_auth_method: "client_secret_basic" and store the client_secret Resend returns. That secret is shown only once at registration, so persist it securely and never expose it in browser or client-side code. It’s then presented on every token and revocation call, in addition to PKCE.
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.
Load the pre-issued client_id and client_secret
Generate PKCE and state
code_verifier, code_challenge, and state. See
above.Store state and code_verifier server-side
Redirect the user's browser to /oauth/authorize
Handle the callback on the app's backend
Reject invalid callbacks
code, mismatched or missing state, or an error query
parameter should all be treated as failures.Exchange the code server-side
code_verifier, and present the client_secret via HTTP
Basic auth.Store the refresh token securely
Serialize refreshes
-u flag sends the client_id and client_secret as HTTP Basic auth, so client_id isn’t repeated in the body:
Local client
A local client must use a loopback redirect URI. Prefer127.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.
Bind a temporary callback server
127.0.0.1 or [::1], never
0.0.0.0.Generate PKCE and state
code_verifier, code_challenge, and state. See
above.Register the client
Open the authorize URL in the user's browser
/oauth/authorize from your process and follow the redirect
yourself. The user needs to see the consent screen.Let the browser follow the redirect to the dashboard consent screen
Handle exactly one callback, then close the server
Reject invalid callbacks
code, mismatched or missing state, or an error query
parameter should all be treated as failures.Exchange the code
code_verifier. See Token.Store the newest refresh token after every token response
Dynamic client registration
Passscope explicitly. If DCR omits scope, Resend registers the client with every supported scope by default.
client_id:
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.
redirect_uri used in the authorization request:
state matches the original state before exchanging the code.
Authorization code exchange
Theredirect_uri in the token request must match the redirect_uri from the authorization request.
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.Revoking access
To disconnect a client, revoke the refresh token:client_secret with curl -u 'CLIENT_ID:CLIENT_SECRET' instead of sending client_id in the body.
Access tokens are JWTs and can’t be revoked individually. Revoking the refresh token revokes the grant. See Revoke Token.