Developers

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 accountAPI key
An internal CLI or dashboard for your teamAPI key (or a session cookie if it’s a browser tool)
A server-to-server cron job provisioning numbers on one accountAPI key
An integration where each end-user signs in with their own DIDHub accountOAuth
A multi-tenant SaaS that resells or builds on DIDHub for its customersOAuth, one registered app, many users
A Zapier-style connector or a distributed desktop / native clientOAuth (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:

FieldWhat it’s for
NameShown to users on the consent screen. Make it the name they’ll recognize.
Description / homepage URL / logo URLAlso rendered on the consent screen. Optional but worth it, users are far more likely to authorize an app they recognize.
Redirect URIsOne 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.
ScopesThe 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.

Exact-match redirect URIs are a security feature, not a nuisance. A wildcard or prefix match is the classic open-redirect hole that lets an attacker exfiltrate authorization codes. DIDHub matches the full string, so register every callback URL you actually use, including your local-dev one.

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.
Codes are single-use. Replaying an authorization code doesn’t just fail, it revokes the access and refresh tokens that code originally minted (RFC 6749 §10.5), on the assumption the code may have leaked. Exchange each code exactly once.

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:

Always persist the newest refresh token and discard the old one. Every refresh hands you a new one. If your code keeps reusing the original, you’ll trip the replay detector and break the integration.

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-server

6. 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.

ScopeWhat it grants
account:readRead the account, name, email, country, account-level settings.
account:writeUpdate name, country, and other account-level settings.
numbers:readList phone numbers, their status, and routing.
numbers:writeOrder numbers, change routing, attach compliance, release numbers.
sip_trunks:read / sip_trunks:writeRead / manage SIP trunks and their gateways.
routing:read / routing:writeRead / manage call-routing profiles and target chains.
cdrs:readList Call Detail Records, caller, callee, duration, billing.
billing:readRead balance, billing transactions, saved payment methods.
compliance:read / compliance:writeRead / 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.

Two things are deliberately not OAuth-grantable. 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 S256 is 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

Ready to get a number?

Pick a DID in 130+ countries from $1.99/month. Activates instantly on most numbers.