Building Integrations on DIDHub: OAuth 2.0 for Third-Party Apps
If you’re building an app that DIDHub users sign into with their own account, a Zapier-style integration, a multi-tenant SaaS, a desktop client, API keys are the wrong tool. An API key is your credential for your account; you can’t hand it to someone else’s users, can’t scope it per user, and can’t give them a “Disconnect” button. This is exactly what OAuth solves. DIDHub now runs a full OAuth 2.0 authorization server, and here’s how to integrate against it.
2026-05-26 · 9 min read
By Daria Kesselman · DIDHub editorial
1. OAuth vs API keys, when to use which
DIDHub supports two ways to authenticate against the /v1/* API: Bearer API keys and OAuth 2.0 access tokens. They solve different problems, and picking the wrong one leads to architecture you’ll have to unwind later.
The one-line rule: API key = you’re the customer. OAuth = your users are the customer. If the credential authenticates your backend talking to your DIDHub account, use an API key. If each end-user logs in with their own DIDHub account and your app acts on their behalf, use OAuth.
| You’re building… | Recommended |
|---|---|
| Your own backend talking to your own DIDHub account | API key |
| An internal CLI or dashboard for your team | API key (or a session cookie if it’s a browser tool) |
| A server-to-server cron job provisioning numbers on one account | API key |
| An integration where each end-user signs in with their own DIDHub account | OAuth |
| A multi-tenant SaaS that resells or builds on DIDHub for its customers | OAuth, one registered app, many users |
| A Zapier-style connector or a distributed desktop / native client | OAuth (with PKCE) |
API keys never expire on their own, can’t be granularly scoped per user, and don’t give the end-user a “Disconnect this app” UI. The moment more than one person’s DIDHub account is involved, those become liabilities, and OAuth becomes the answer.
2. Register your app
In OAuth terms, DIDHub is the Authorization Server and your app is the client. Before you can send anyone through the flow, register the client. Sign in to the dashboard and go to Settings → OAuth apps → Register OAuth app. You’ll provide:
| Field | What it’s for |
|---|---|
| Name | Shown to users on the consent screen. Make it the name they’ll recognize. |
| Description / homepage URL / logo URL | Also rendered on the consent screen. Optional but worth it, users are far more likely to authorize an app they recognize. |
| Redirect URIs | One per line. Matched exactly at /oauth2/authorize. https:// only, except http://localhost / http://127.0.0.1 (any port) for local dev. Custom schemes like com.example.app://oauth are allowed for native apps. |
| Scopes | The maximum set of scopes this app is ever allowed to request. Users can narrow further on the consent screen, but never widen past this ceiling. |
On submit you receive a client_id and a client_secret. The secret is shown once, copy it straight into your secrets backend. Lost it? Click Rotate secret to mint a new one; the previous secret stops working immediately.
3. The Authorization Code + PKCE flow, end to end
DIDHub implements Authorization Code with PKCE (RFC 6749 + RFC 7636). PKCE is mandatory, there is no implicit-grant path, no code_challenge_method=plain, and you cannot skip code_challenge. S256 is the only accepted method. This applies to confidential clients (the ones with a secret) too, not just public/native apps. It’s a small amount of extra code that closes the authorization-code-interception attack outright, so we require it everywhere.
Generate the PKCE pair
Before the redirect, create a one-time pair:
code_verifier, a cryptographically random 43–128 character string. Keep it server-side (or in app memory) for the duration of the flow.code_challenge,BASE64URL(SHA256(code_verifier)). This is what you put in the authorize URL; the raw verifier never travels over the front channel.
Most OAuth libraries generate this for you. If you’re rolling it by hand, it’s a random-bytes call, a SHA-256 hash, and a base64url-encode (no padding).
Send the user to /oauth2/authorize
Redirect the user’s browser to the authorize endpoint with the challenge attached (line-wrapped here for readability, send it as a single URL):
https://api.didhub.io/oauth2/authorize? response_type=code &client_id=didhub_oauth_… &redirect_uri=https://app.example.com/oauth/callback &scope=numbers:read%20cdrs:read &state=<random-csrf-token> &code_challenge=<base64url-sha256-of-verifier> &code_challenge_method=S256
state is a random string you generate and verify when the user comes back, it’s your CSRF defense (RFC 6749 §10.12). Multiple scopes are space-delimited (URL-encoded as %20).
The user lands on DIDHub’s consent screen at dashboard.didhub.io/oauth-consent. If they’re not signed in, they sign in first. The screen shows your name, logo, and the human-readable description of every scope you asked for. When they click Authorize, DIDHub redirects back to your registered redirect_uri:
https://app.example.com/oauth/callback?code=<one-shot-code>&state=<your-state>
First thing on the callback: verify state matches what you sent. If they click Cancel instead, you get ?error=access_denied&error_description=…&state=…, handle that path gracefully.
4. Exchange the code for tokens
The authorization code is useless on its own, it’s a short-lived, single-use voucher. Exchange it server-side by POSTing to the token endpoint as application/x-www-form-urlencoded:
POST https://api.didhub.io/oauth2/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=<one-shot-code-from-callback> &redirect_uri=https://app.example.com/oauth/callback &client_id=didhub_oauth_… &client_secret=didhub_oauths_… &code_verifier=<the-original-pkce-verifier>
The code_verifier is how the server proves you’re the same party that started the flow: it hashes what you send and checks it against the code_challenge from step 3. The redirect_uri must be identical to the one you authorized with. If you’d rather not put credentials in the body, send them as an HTTP Basic header instead, Authorization: Basic base64(client_id:client_secret) (RFC 6749 §2.3.1). On success you get:
{
"access_token": "eyJhbGciOiJIUzI1NiJ9...",
"refresh_token": "didhub_oauthr_…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "numbers:read cdrs:read"
}
What you get back:
- The access token is a stateless JWT with a 1-hour lifetime. Send it as
Authorization: Bearer eyJ…to any/v1/*endpoint the granted scopes cover. Because it’s stateless, DIDHub verifies it without a per-request database lookup. - The refresh token is an opaque string. Store it securely server-side, it’s how you mint the next access token when this one expires.
5. Refresh-token rotation and replay detection
Access tokens last an hour. Before yours expires, trade your refresh token for a new one:
POST https://api.didhub.io/oauth2/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token=didhub_oauthr_… &client_id=didhub_oauth_… &client_secret=didhub_oauths_…
This returns a fresh access_token and a brand-new refresh_token. DIDHub rotates the refresh token on every single use (RFC 6749 §10.4 best practice). The single most important rule for integrating against this:
Here’s why rotation matters. If an attacker steals a refresh token, both they and your app now hold what looks like the same valid token. The first one to use it gets rotated to a new value; when the second party presents the now-stale token, DIDHub knows one of them is an impostor, but it can’t tell which. So it takes the safe action: presenting an already-rotated (old) refresh token revokes the entire token chain for that grant. Both parties are cut off, and the user simply re-authorizes. A stolen token is therefore useful for, at most, one refresh before the theft is detected and the chain dies.
In practice this means: store the refresh token where only one writer touches it, update it atomically on every refresh, and never run two refreshers against the same grant concurrently (a race will look exactly like a replay). You can narrow scope on refresh by passing &scope=numbers:read, but you cannot widen it, getting more scope requires a fresh trip through /oauth2/authorize.
Revoking
When a user disconnects, or you’re done with a grant, revoke the refresh token:
POST https://api.didhub.io/oauth2/revoke Content-Type: application/x-www-form-urlencoded token=didhub_oauthr_… &client_id=didhub_oauth_… &client_secret=didhub_oauths_…
Per RFC 7009, /oauth2/revoke always returns 200, so an attacker can’t probe for valid tokens by reading the status code. Access tokens are stateless JWTs that simply expire within the hour, so the revoke endpoint deals in refresh tokens. Users can also self-serve from Settings → OAuth apps → Connected applications → Disconnect, which kills the consent grant and every live refresh token for that user-and-app pair.
Discovery
If your OAuth library auto-configures from a metadata URL, point it at the RFC 8414 discovery document, it advertises every endpoint, the supported grant and response types, the scope list, and the supported PKCE methods:
https://api.didhub.io/.well-known/oauth-authorization-server6. Scopes and the least-privilege rule
Scopes are named <resource>:<action>, with two actions: read and write. Crucially, :write implies :read for the same resource, granting numbers:write already lets you call GET /v1/numbers, so you never need to request both.
| Scope | What it grants |
|---|---|
account:read | Read the account, name, email, country, account-level settings. |
account:write | Update name, country, and other account-level settings. |
numbers:read | List phone numbers, their status, and routing. |
numbers:write | Order numbers, change routing, attach compliance, release numbers. |
sip_trunks:read / sip_trunks:write | Read / manage SIP trunks and their gateways. |
routing:read / routing:write | Read / manage call-routing profiles and target chains. |
cdrs:read | List Call Detail Records, caller, callee, duration, billing. |
billing:read | Read balance, billing transactions, saved payment methods. |
compliance:read / compliance:write | Read / manage end-users, compliance documents, and bundles. |
Request the narrowest scope set that does the job. If your integration only reads call records, ask for cdrs:read, not numbers:write “just in case.” A leaner consent screen earns more authorizations, and a smaller blast radius is one fewer thing to explain if a token ever leaks. You can always narrow on refresh; widening requires sending the user back through consent.
billing:write (moving money, topups are session-cookie only, so an app installed by a lower-privileged user can’t drain the account’s card) and oauth:apps (managing OAuth apps via OAuth would let a compromised app mint more apps, a privilege-escalation loop). Both stay locked to first-party session auth by design.7. Bottom line
If your users bring their own DIDHub accounts, OAuth is the right and only sane choice, and DIDHub’s implementation is deliberately boring in the ways that matter:
- Use OAuth when your users are the customer; use an API key when you are. That one distinction decides everything else.
- PKCE with
S256is mandatory for everyone, confidential clients included. Generate the verifier/challenge pair before you redirect. - Exchange each authorization code exactly once, server-side, replaying it revokes the tokens it minted.
- Persist the newest refresh token on every refresh and never reuse an old one. Rotation plus replay-detection means a stale refresh token tears down the whole grant.
- Ask for the least scope that works. Narrowing is cheap; widening costs a re-consent.
Lean on a standards-compliant OAuth client library where you can, DIDHub is a textbook RFC 6749 + 7636 server, so point your library at /.well-known/oauth-authorization-server to auto-configure the endpoints. For the full reference, see the auth reference in the docs, and try the endpoints live in the interactive API explorer.
Don’t have an account to register an app against yet? Create a DIDHub account, the OAuth apps screen is under Settings, and you can have a client registered in under a minute.
More from the blog
A2P 10DLC Registration: Getting Your US SMS Actually Delivered
US carriers require A2P traffic on 10-digit long codes to be registered through The Campaign Registry. We explain brand + campaign registrat
AI Voice Agents Need Real Phone Numbers
Vapi, Retell AI, ElevenLabs, Bland, Synthflow, LiveKit Agents, Pipecat, AI voice platforms need real DIDs with STIR/SHAKEN A-attestation, re
Branded Calling Explained: Logo & Verified Name on Caller ID
Complete guide to branded calling: the four major programs (Hiya, First Orion, TNS, Google Verified Calls), how Rich Call Data fits, what sh
CNAM & Caller ID: Why Your Business Name Doesn’t Show
In North America the receiving carrier looks up your caller ID name via a CNAM dip, you don't send it. We explain why your business name doe
Ready to get a number?
Pick a DID in 130+ countries from $1.99/month. Activates instantly on most numbers.