Signing in your own users
The default integration assumes your backend mints the session: it holds
your client credentials, calls POST /v1/sdk/sessions, and hands the browser the
result. That is the right shape when you have a backend willing to add a route.
If your users are already signed in on your own site, there is a shorter path. Your page hands the SDK the access token your user already holds, the browser trades it for a DriveCars session, and we find out who that user is by asking you. Your backend never enters the picture.
DriveCars.init({
authProvider: 'partner',
clientId: 'dc_ci_...', // public. It is in your page source.
accessToken: yourUsersAccessToken, // your own token, from your own auth
environment: 'sandbox',
lang: 'en',
});No session. No onRefreshToken. Those are the two things the default path
needs a backend for, and this path needs neither.
What you have to build
One endpoint: token introspection. We POST it the access token your page
just handed us, and it tells us who that token belongs to.
POST https://auth.yoursite.com/introspect
Content-Type: application/json
Accept: application/json
Authorization: Bearer <the secret you gave us, if you configured one>
{ "token": "<your end user's access token>" }Answer 200 with:
{
"active": true,
"sub": "usr_123",
"email": "ada@example.com",
"name": "Ada Lovelace"
}That is RFC 7662 (OAuth 2.0 Token Introspection), on purpose. If you already run an OAuth server you almost certainly have this endpoint already. If you do not, the shape above is the whole specification.
| Field | Required | Notes |
|---|---|---|
sub | Yes | A stable identifier for the user. user_id and id are accepted as aliases. A non-empty string, or a finite number (we stringify it). |
active | No | An explicit false is a rejection, even at HTTP 200 — that is RFC 7662’s design. Omit the field entirely and we judge on sub alone. |
email | No | Echoed back to the SDK so you can render “signed in as”. |
name | No | As above. Trimmed to 320 characters. |
email and name are genuinely optional. If your privacy posture says the
subject and nothing else, the integration still works completely.
An object, array or boolean in sub is refused rather than stringified. It has
to be, and it is worth knowing why: "[object Object]" is a perfectly stable
string, and accepting it would collapse every user you have onto one DriveCars
customer.
We call it server-side
The request comes from our servers, not from your user’s browser. That means:
- No CORS. You do not need
Access-Control-Allow-Originon it, and it does not need to be reachable from a browser at all. - You can authenticate it. Give us a bearer secret and we present it as
Authorization: Bearer …on every call. Optional — plenty of introspection endpoints are fine unauthenticated behind a token requirement — but available. - It must be reachable from the public internet. See what your URL must satisfy.
The budget
Five seconds, wall clock. Not an idle timeout — a total deadline, so an endpoint trickling bytes will not hold it open. A browser is blocked on this call while your user waits for a search box to appear, which is why it is half the ten seconds we allow a webhook delivery.
We buffer at most 64 KB of your response and never follow a redirect. A 3xx is
a failure, exactly like a 500.
client_id is public. client_secret never is.
Your client_id belongs in your page source. It is public by design, in the
same way a card processor’s publishable key is, and putting it there is not a leak.
Your client_secret must never reach a browser under any circumstances. It is
your server credential; anyone who reads it can transact as you.
The reason the exchange is safe without a secret is worth stating plainly, because “an endpoint that takes no authentication” sounds alarming until you see what is actually holding it up:
The client_id grants nothing on its own. All it does is select whose auth
server we go and ask. The exchange still fails unless your introspection
endpoint vouches for the end-user token presented alongside it. Someone who
scrapes a client_id out of your page has a name, not a credential — they would
still need a real, live access token from your own auth system, which is the
same thing they would need to impersonate that user on your own site.
Three more properties back that up:
| Origin binding | A web exchange must arrive from an origin on your allowlist. A scraped client_id replayed from an attacker’s page is 403. |
A rate limit on your client_id | 20 requests per minute. It bounds both brute-forcing your token space and using us to hammer your auth server. |
| An explicit opt-in | The exchange only works when your partner account has auth_provider = partner. A partner who never asked for this gets a 409, never a guest session. |
Do not try to “harden” this by putting a client_secret in the page. A browser
cannot hold one, and the moment you do, the secret is public and the whole
credential is worthless.
What happens when things fail
Every failure lands on tokenRefreshFailed with an SdkExchangeError carrying
both status and code, and the SDK logs a line naming what you should do.
The four are deliberately distinguishable, because they call for four different
actions:
| Status | code | What it means | What to do |
|---|---|---|---|
401 | partner_auth_rejected | Your auth server did not accept the token. Their session with you has expired. | Have the user sign in again on your site, then call init() with the new accessToken. |
502 | partner_auth_unavailable | We could not reach your auth server, or it answered something we could not read. | Do not sign the user out. This is not their fault and a retry may work. |
409 | auth_provider_not_partner | Your partner account is not switched to partner auth. | Ask us to enable it, or use POST /v1/sdk/sessions from your backend. |
404 | unknown_client | No partner matches that client_id. | Check the clientId you passed to init(). |
A 403 is also possible: origin_not_allowed (the browser origin is not on
your allowlist) or partner_inactive.
The 401/502 split is the one that matters. Collapsing them into “auth failed” means a DNS blip on your side logs all your customers out. They are two different sentences in the SDK’s console output for exactly that reason.
Your auth server being down does not break the widget
The customer sees an element that has not signed in — not a broken page and not
a stack trace. During the exchange every mounted element carries
data-dc-session-pending, which you can style as a skeleton; when the exchange
fails the marker is removed, no token is written, and the element sits there
having made no request it would only have got a 401 for.
Nothing from your auth server ever reaches the customer’s browser. Whatever your
endpoint said — a stack trace, an internal hostname, another user’s record — is
logged on our side and becomes exactly 401 partner_auth_rejected with our own
wording. That is not a courtesy to us; it is what stops your internals leaking
through our widget.
Refresh
There is no onRefreshToken to write. When the session token nears expiry (or
an element sees a 401), the SDK re-exchanges the same accessToken — one
round trip through your introspection endpoint, no backend.
Once your auth server rejects that token with a 401, the SDK latches: it
stops re-exchanging until the next init(). A dead token would otherwise
produce one failed exchange per mounted element per 401, which is a burst of
traffic at your auth server for a session that cannot be recovered. Calling
init() again with a fresh accessToken — which is what you do when your user
signs back in — clears the latch.
You may still pass onRefreshToken on this path, and it wins if you do. That
is worth it only if you have a backend that can mint a session directly:
POST /v1/sdk/sessions is cheaper than a round trip through your own auth
server.
The customer we resolve
The exchange echoes back the customer it resolved, so you can render “signed in as” without a second call:
{
"session_token": "eyJhbGciOi...",
"expires_at": "2026-09-10T12:15:00.000Z",
"partner": { "slug": "acme", "...": "..." },
"customer": {
"ref": "acme:usr_123",
"email": "ada@example.com",
"name": "Ada Lovelace"
}
}customer.ref is namespaced with your partner slug, not the bare sub you
returned. Two partners can both call a user 1, and an unprefixed reference
would silently merge their customers the day a second partner turned this on.
email and name are only ever what you told us about your own user. Nothing
is derived, and nothing comes from our side.
What your introspection URL must satisfy
We make an authenticated outbound request to an address you choose, from inside our network. The same guard that protects our webhook delivery applies here, and it will refuse a URL both when ops saves it and again on every call — a hostname that resolved to a public address the day it was configured can resolve somewhere else tomorrow.
| Rule | |
|---|---|
| HTTPS on port 443 | http:// and other ports are refused. |
| A hostname, not an IP literal | https://203.0.113.10/introspect is refused. |
| No credentials in the URL | https://user:pass@host/… is refused. Use the bearer secret. |
| It must resolve to a public address | Private (RFC 1918), loopback, link-local, CGNAT, multicast and IPv6 equivalents are all refused — including IPv4-mapped and NAT64 spellings of them. |
Not localhost, *.internal, *.amazonaws.com or *.drivecars.ai | Refused by hostname, trailing dot included. |
| Every resolved address must pass | A hostname with one public and one private A record is refused outright. |
| Redirects are never followed | A 3xx is a failed introspection. If your endpoint moves, tell us the new URL. |
The connection is pinned to the exact address that validation resolved, so the hostname is never resolved a second time between the check and the connect.
Turning it on
auth_provider is a setting on your partner account, not something you pass at
runtime. Ask us to switch it, and give us:
- Your introspection URL.
- The bearer secret we should present to it, if it needs one.
Until it is switched, the exchange answers 409 auth_provider_not_partner —
deliberately, rather than quietly issuing a guest session to anyone holding
a client_id.
init() on this path
| Option | Type | Notes |
|---|---|---|
authProvider | 'partner' | Required to take this path. Omitted (or 'drivecars') is the default backend-minted integration. |
clientId | string | Required. Your public client id. |
accessToken | string | Required. Your own end user’s token. Opaque to us — it is forwarded to your introspection endpoint and nowhere else. |
session | — | Not accepted. There is no session yet; the exchange produces it. |
onRefreshToken | callback | Optional here. Wins over re-exchange when given. |
environment, lang, currency, theme, onEvent | Exactly as on the default path. |
init() stays synchronous. It starts the exchange and returns; elements mount
immediately, tokenless and marked pending, and pick up the token when it lands.
Mount your elements in the DOM before init(), exactly as on the default
path — that ordering is what lets the SDK sweep them.
Calling init() without clientId or accessToken on this path logs a console
error naming the missing field and starts no exchange, leaving whatever was
already configured intact.