Cargo Accounts · One Login · Integration Guide
Internal App Migration Guide
For internal tools that keep their own data backend. Same Model 2 flow: register Cargo as a Custom OIDC Provider with Auto-discovery OFF (manual endpoints; issuer copied from live discovery), one signInWithOAuth('custom:cargo-ac') button, native session minted locally, automatic JIT provisioning and global logout.
Apps this guide covers
CW Network HUB
Exchange
CargoWorks ID
Expert
Support
Delivery
CloudThe ecosystem at a glance
┌──────────────────┐ passwords live ONLY here
│ one.cargo.ac │ login · signup · reset
│ (account home) │◄───────────────┐
└────────┬─────────┘ │
return-redirect │ setSession() │ same-window
(?return=...) ▼ │ handoff
┌──────────────────┐ ┌───────┴────────┐
│ auth.cargo.ac │ │ CW Network HUB │
│ tokens · JWKS · │◄──────►│ (this spoke) │
│ shared session │ silent │ cargoworks.ne │
└──────────────────┘ probe └────────────────┘
1 button · 1 tab · automatic return — no popup, no manual reopen.The uniform button — both states
Every app renders this exact control. Left: no session yet. Right: silent probe found a session, so one click continues.
No session
Session detected
1. Brand your OWN data backend first (custom domain)
Do this BEFORE adding the provider. Your data backend must be served from your OWN branded domain (e.g. https://api.<your-app>), not the raw vendor host. The OIDC Callback URL the form shows in step 4 is generated from THIS host — if your backend still answers on a raw vendor URL (something.<vendor>.co), the callback will be a vendor URL too, and Cargo will NOT allowlist it. In your backend dashboard go to Settings → Custom Domains, activate api.<your-app>, and confirm it is the project's live API URL. Only then continue.
// Your backend's PUBLIC API URL must be your branded domain: https://api.<your-app> ✅ branded https://<ref>.<vendor>.co ❌ raw vendor host — NOT acceptable for go-live // Settings → Custom Domains → Activate, then verify it is the LIVE API URL. // Everything below (including the Callback URL) inherits THIS host.
2. Turn OFF every local auth method on your OWN backend
Critical: https://api.<your-app> stays your DATA backend, but it must STOP authenticating people itself. In your backend's Authentication settings disable Email/password, Phone, magic links/OTP and any social provider (Google, etc.). Do NOT use the "Third-Party Auth" screen — that is the wrong feature for this. Remove every password field, signup and reset form from this internal tool; the only entry point becomes "Continue with Cargo".
// DELETE / DISABLE on this app AND in your backend dashboard: supabase.auth.signInWithPassword(...) // ❌ no password sign-in supabase.auth.signUp(...) // ❌ no signup here supabase.auth.resetPasswordForEmail(...) // ❌ no reset here supabase.auth.signInWithOtp(...) // ❌ no magic links / OTP <input type="password" /> // ❌ remove entirely // Dashboard: Authentication → Providers → turn Email/Phone/Google OFF. // ⚠️ Do NOT add anything under Authentication → Third-Party Auth.
3. Register Cargo as a Custom OIDC Provider (Auto-discovery OFF)
RULE A (permanent, by design): the public auth domain (auth.cargo.ac) and the OIDC "issuer" are intentionally DIFFERENT. The issuer is whatever LIVE discovery returns — the identity provider's backend host — and it will NOT change to auth.cargo.ac. So in your backend go to Authentication → Providers → Add provider → "Custom OIDC Provider" (NOT "Third-Party Auth"), turn Auto-discovery OFF, and enter the endpoints manually. Set the Issuer to the EXACT value you copy from https://auth.cargo.ac/auth/v1/.well-known/openid-configuration (the "issuer" field — the backend host), and point the visible Authorization/Token/Userinfo/JWKS endpoints at auth.cargo.ac. This keeps the browser branded while the JWT's "iss" claim carries the backend host (which is what makes validation pass). Paste the confidential Client ID + Secret Cargo sends you out of band, and keep "Allow users without email" OFF — Cargo always returns an email. This is the TARGET configuration; it is promoted to permanent only after the CargoWorks AI pilot passes Gate 2.
Provider type = Custom OIDC Provider Provider name = custom:cargo-ac Auto-discovery = OFF Issuer = <EXACT "issuer" copied from live discovery — backend host> Authorization endpoint = https://auth.cargo.ac/auth/v1/oauth/authorize Token endpoint = https://auth.cargo.ac/auth/v1/oauth/token Userinfo endpoint = https://auth.cargo.ac/auth/v1/oauth/userinfo JWKS endpoint = https://auth.cargo.ac/auth/v1/.well-known/jwks.json Client ID = <sent privately by Cargo — confidential> Client Secret = <sent privately by Cargo — confidential> Scopes = openid profile email Allow users w/o email = OFF // Cargo always returns an email // Read the issuer first: curl https://auth.cargo.ac/auth/v1/.well-known/openid-configuration // Paste its "issuer" verbatim — never type auth.cargo.ac as the issuer.
4. Copy the form's Callback URL and confirm it to Cargo
The provider form shows a Callback (redirect) URL on YOUR data backend — it looks like the one below. Copy it verbatim and send it to Cargo so it is added to the hub allowlist. Redirect matching is EXACT (scheme, host, path, no trailing slash). This branded callback replaces the old per-app /auth/callback + /auth/silent URLs, which are now retired.
Callback URL = https://api.<your-app>/auth/v1/callback // Send EXACTLY this string to Cargo to allowlist. // 🛑 STOP if this shows a *.<vendor>.co (raw vendor) host: your custom // domain isn't the live API URL yet — finish step 1 first. // ❌ Do NOT use https://<app-origin>/auth/callback (retired). // ❌ Do NOT use https://<app-origin>/auth/silent (retired).
5. Make the login button call signInWithOAuth('custom:cargo-ac')
Replace your old login UI with one button. Calling signInWithOAuth with the provider you registered redirects to auth.cargo.ac, the user signs in once on Cargo, and your backend finishes the exchange at the Callback URL and mints its OWN native session. SSO is automatic: if a Cargo session already exists the user is returned immediately.
await supabase.auth.signInWithOAuth({
provider: "custom:cargo-ac",
options: { redirectTo: "https://cargoworks.network" },
});
// Read the resulting native session like any normal client session:
supabase.auth.onAuthStateChange((_event, session) => { /* ... */ });6. Users are provisioned automatically — do NOT build provisioning
The first time a permitted Cargo user signs in, your backend AUTO-CREATES their row in auth.users + an identities row that links provider "custom:cargo-ac" to their canonical Cargo id (the id_token "sub"). This is standard Just-In-Time provisioning — exactly like the first "Sign in with Google". Every later login matches that identity and reuses the same user (one shadow user per person). If you keep a handle_new_user trigger on auth.users, your profiles row is created in the same moment. Write zero provisioning code.
// Automatic on first login — nothing for you to build: auth.users ← new row (your OWN local uuid) auth.identities ← provider="custom:cargo-ac", provider_id = Cargo "sub" public.profiles ← created by your handle_new_user trigger (recommended) // Your RLS keeps using auth.uid() (your local id). // To correlate back to Cargo, read the identity's provider_id (sub).
7. Defensively verify entitlement with the app_access claim
Provisioning is NOT authorization. Cargo only issues a token for your app when the user's organization has an active grant for it (enforced at the hub consent screen), so unentitled users never reach you. As a belt-and-braces check, read the "app_access" claim after sign-in: if your app key ("network") isn't in it, sign the user out and show "request access". Internal staff receive `internal` (or higher) roles managed centrally in admin.cargo.ac.
const { data } = await supabase.auth.getSession();
const claims = data.session?.user?.app_metadata ?? {};
// app_access is also present in the access-token JWT claims:
const allowed = (claims.app_access ?? []).includes("network");
if (!allowed) {
await supabase.auth.signOut();
showRequestAccess(); // user is authenticated but not entitled
}8. Wire global single logout
Sign-out should end your local session AND the shared Cargo session so the user is logged out everywhere. Call your backend's signOut(), then hit the Cargo global logout endpoint. Keep your tables, storage and RLS exactly as they are — nothing else changes.
await supabase.auth.signOut(); // ends THIS app's native session
await fetch("https://auth.cargo.ac/auth/v1/logout", { method: "POST", credentials: "include" });
// next sign-in goes through Cargo againWorked example — CW Network HUB (cargoworks.network)
A user opens cargoworks.network and clicks "Continue with Cargo". Their backend redirects to auth.cargo.ac; they sign in once on Cargo (or are returned instantly if a Cargo session already exists). Their backend finishes the code exchange at api.<your-app>/auth/v1/callback, JIT-provisions the user on first visit, mints its OWN native session, and lands them on the dashboard.
① Open cargoworks.network → click "Continue with Cargo"
supabase.auth.signInWithOAuth({ provider: "custom:cargo-ac" })
② Redirect to https://auth.cargo.ac → user authenticates / consents
③ Code returned to https://api.<your-app>/auth/v1/callback
backend verifies id_token vs hub JWKS, JIT-creates the user,
mints its OWN native session.
④ Spoke checks app_access includes "network" → shows dashboard ✅
✅ spoke mints its own session ❌ no shared JWT secret
✅ users auto-provisioned (JIT) ❌ no hand-rolled /auth/silent probeWhat to delete, keep and add
- DELETE: your own login / signup / reset forms and all password fields
- DELETE: signInWithPassword / signUp / resetPasswordForEmail / OTP / social calls
- DELETE: every local auth provider (Email/Phone/Google) in your backend dashboard
- DON'T: use the "Third-Party Auth" screen — it's the wrong feature for this
- DELETE: any custom /auth/callback + /auth/silent code (retired)
- KEEP: your own data backend, tables, storage and RLS — exactly as-is
- ADD FIRST: a branded custom domain (api.<your-app>) as your live API URL — no vendor-host callback
- ADD: Cargo as a Custom OIDC Provider (Auto-discovery OFF, manual endpoints, issuer = live-discovery backend host) 'custom:cargo-ac', Allow-users-without-email OFF
- ADD: the confidential Client ID + Secret Cargo sent you (out of band)
- ADD: a single button calling signInWithOAuth('custom:cargo-ac')
- ADD: a defensive app_access claim check after sign-in (sign out if not entitled)
- ADD: global single logout (signOut + Cargo global logout)
Pitfalls & FAQ
- Why is the Callback URL grayed out and showing a vendor (supabase.co) address?
- That field is your backend's OWN fixed redirect endpoint — it always equals your project's live API base URL + /auth/v1/callback, so it can't be typed or edited. It shows a vendor host only because your branded custom domain isn't promoted to the live API URL yet. Activate api.<your-app> under Settings → Custom Domains and the callback rebrands itself automatically. Never send a vendor-host callback to Cargo — it won't be allowlisted.
- Should "Allow users without email" be ON?
- No — keep it OFF. Cargo requests the email scope and every Cargo account always has an email, so the identity provider always returns one. That toggle exists only for providers that sometimes omit email; turning it ON would permit emailless ghost users and break your email-based JIT provisioning. Leave it OFF on every spoke.
- The form wants a Client ID / Client Secret I don't have.
- That's expected. Cargo issues a confidential Client ID + Secret per app from admin.cargo.ac and sends them to you out of band. Leave both fields empty until Cargo delivers them — you do not generate these yourself.
- Which provider type do I pick — "Third-Party Auth" or "Custom OIDC Provider"?
- Custom OIDC Provider (OpenID Connect) with Auto-discovery OFF. The "Third-Party Auth" screen is a different feature (it makes your backend trust foreign tokens and would require sharing the hub JWT secret). Do NOT use it. The Custom OIDC Provider makes your backend a relying party that mints its own native session.
- Should I turn Auto-discovery ON to make setup easier?
- No — keep Auto-discovery OFF and enter endpoints manually. RULE A: the OIDC issuer is the identity provider's backend host, not auth.cargo.ac, by design. If Auto-discovery is ON the provider adopts that issuer AND routes the browser to the issuer host, leaking the vendor host to users. With it OFF you set Issuer = the backend host (so the token's "iss" validates) while the visible Authorization/Token/Userinfo/JWKS endpoints stay on auth.cargo.ac — branded browser, valid token.
- What do I enter as the Issuer, and why isn't it auth.cargo.ac?
- Enter the EXACT "issuer" value returned by live discovery (curl https://auth.cargo.ac/auth/v1/.well-known/openid-configuration) — it is the backend project host, copied verbatim, never invented. RULE A makes this permanent and by design: the public auth domain and the OIDC issuer are intentionally different. The identity provider has confirmed the issuer will remain the backend host and will not switch to auth.cargo.ac. Pointing the Issuer at auth.cargo.ac instead would cause an "issuer mismatch" because the signed token's "iss" is the backend host. JWT validation must always use this live-discovery issuer.
- When does a user actually get created in MY backend, and do I write code for it?
- On their first successful Cargo login your backend auto-creates the user (auth.users + an identities row linking custom:cargo-ac to their Cargo sub). This is Just-In-Time provisioning — identical to first-time Google sign-in. You write zero provisioning code. Later logins reuse the same user.
- Is every Cargo user added to my backend, even ones not allowed to use my app?
- No. Cargo only issues a token for your app to users whose organization has an active grant for it (enforced at the hub consent screen), so unentitled users never finish the flow and are never provisioned. As a backstop, check the app_access claim after sign-in and sign out anyone whose app key isn't present.
- Is the user's id in my backend the same as their Cargo id?
- No. Your backend generates its OWN local UUID for auth.users. The canonical Cargo id is the id_token "sub", stored on the identities row (and in user metadata). Use auth.uid() for your RLS; use sub to correlate back to Cargo.
- Do my existing tables and RLS still work?
- Yes, untouched. Your backend mints a normal native session, so auth.uid() and every RLS policy keep working exactly as before. Nothing about your data layer changes.
- Is a shared JWT secret involved anywhere?
- Never. Verification is asymmetric via the hub JWKS (the JWKS endpoint you set on auth.cargo.ac). No secret ever leaves auth.cargo.ac — a spoke compromise can never forge hub tokens.
- Can I keep a password field 'just for convenience'?
- No. All local auth providers must be OFF. Passwords are entered only on Cargo. A password field on a spoke is a phishing surface and breaks the model.
9. Test checklist
Confirm each item before flipping production traffic to cargo.ac.
- Your data backend is served from your BRANDED custom domain (api.<your-app>), not a vendor host
- Every local auth provider (Email/Phone/Google) is OFF in your backend
- No password field, signup form, or reset screen anywhere in this app
- Cargo is registered as a Custom OIDC Provider with Auto-discovery OFF, NOT Third-Party Auth
- "Allow users without email" is OFF; Scopes = openid profile email
- Issuer = the EXACT live-discovery value (backend host), copied not invented
- Manual endpoints (authorize/token/userinfo/jwks) all point at auth.cargo.ac
- Confidential Client ID + Secret entered
- The branded Callback URL (api.<your-app>/auth/v1/callback) was sent to Cargo and allowlisted — NOT a *.<vendor>.co URL
- Login button calls signInWithOAuth('custom:cargo-ac')
- First login JIT-provisions the user and mints your OWN native session (RLS still works)
- After sign-in the app verifies the app_access claim and signs out non-entitled users
- Identity-contract claims present after login, refresh, rotation + org switch: username, cargo_account_number, identity_type, user_type, permissions, orgs, active_org, app_roles, amr, scope
- Global logout: signOut() + Cargo global logout both fire
- End-to-end verified: button → Cargo → back to your callback, signed in







