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
What your users see — they sign in & create accounts on one.cargo.ac
Read this first — it is the part people get wrong. Your internal tool shows ONE button ("Continue with your Cargo Account"). It does NOT take the user to the admin console, and it does NOT ask them to type a password on your site. The button calls the Cargo identity provider, and Cargo routes the user to the Cargo Account user console at one.cargo.ac — the ONLY place a human ever signs in or creates an account. They sign in there, approve access once, and are sent straight back into your app already signed in. Do NOT hardcode one.cargo.ac yourself and do NOT redirect users there manually — the provider call routes there automatically. auth.cargo.ac is just an invisible, branded redirect hop; the page your users actually see and use is one.cargo.ac.
What the human sees, step by step:
┌ Sign in → one.cargo.ac/login (email + password, or Google)
├ New account→ one.cargo.ac/signup (linked from the login page;
│ verify email, then returned here)
└ First time → one.cargo.ac/oauth/consent ("Allow <App> to access
your Cargo Account")
Full journey (one button, one tab, automatic return):
① Button → signInWithOAuth({ provider: "custom:cargo-ac" })
② auth.cargo.ac/auth/v1/oauth/authorize (invisible branded hop)
③ one.cargo.ac/login → /signup (if new) → /oauth/consent
④ back to YOUR backend https://api.<your-app>/auth/v1/callback?code=…
⑤ your backend exchanges the code, mints its OWN session
⑥ redirectTo → user lands in your app, signed in1. Use your OWN data backend host (raw vendor host is the default)
Point this app's client at your project's REAL/upstream data-backend host — the raw vendor host (e.g. something.<vendor>.co) is the documented default and is exactly what the working pilot (CargoWorks AI) uses. Do NOT route your data/auth traffic through a branded proxy (e.g. a hand-rolled https://api.<your-app>) — branding the data backend breaks the external OAuth code exchange at /auth/v1/callback. The OIDC Callback URL the form shows in step 4 is generated from THIS host, so it will normally be your raw vendor-host callback — that is expected and correct. Branding the API host with your OWN custom domain (api.<your-app>) is an OPTIONAL later upgrade once the raw-host flow is verified working; if you do it, do it through the backend's native Custom Domains setting, never a separate proxy.
// Your backend's data-client points at the REAL upstream host: https://<ref>.<vendor>.co ✅ raw vendor host — the default https://api.<your-app> ↑ optional later upgrade (native custom domain only) // ❌ Do NOT proxy auth/data traffic through a branded host — // it breaks the OAuth code exchange at /auth/v1/callback. // The Callback URL in step 4 inherits whatever host serves the API.
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. CRITICAL: set Token endpoint auth method = client_secret_post. Cargo registers every confidential spoke client as client_secret_post, but the data backend defaults to client_secret_basic — if you leave it on the default the token exchange is rejected with "invalid client credentials" (HTTP 400) and sign-in fails at the callback. 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> Token endpoint auth method = client_secret_post // ⚠️ MUST set — default is _basic and FAILS 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. // Cargo registers confidential clients as client_secret_post; the backend // defaults to client_secret_basic → "invalid client credentials" if unset.
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. This will normally be your RAW vendor-host callback (something.<vendor>.co/auth/v1/callback) — that is expected and correct, not a fallback. 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). Cargo allowlists whatever you actually send, so send the precise string the form shows. This OAuth callback is your backend's code-exchange endpoint; it is NOT the same as any post-sign-in landing page in your app.
Callback URL = https://api.<your-app>/auth/v1/callback // Send EXACTLY this string to Cargo to allowlist. // Normally a raw *.<vendor>.co host — that is correct, send it as-is. // (A branded api.<your-app> callback is only for a later, verified upgrade.) // ❌ Do NOT invent or hand-edit this value — copy the form verbatim.
5. Set Site URL + Redirect URLs (Authentication → URL Configuration)
After the token exchange, your backend redirects the browser to {Site URL}/auth/callback. If Site URL is still http://localhost:3000 (the default for new projects), even the published site lands on a dead localhost page after sign-in. Go to Authentication → URL Configuration: set Site URL to THIS app's production host (remove localhost), then add an Additional Redirect URL (each ending /auth/callback) for the production domain, the *.lovable.app host, and the preview host so production, staging and preview all work. This is purely your backend's setting — it is NOT the Cargo allowlist from step 4.
Site URL = https://cargoworks.network // your production host — NOT localhost Additional Redirect URLs (one per line): https://cargoworks.network/auth/callback https://<your-app>.lovable.app/auth/callback https://id-preview--<project-id>.lovable.app/auth/callback // ⚠️ Remove http://localhost:3000 from Site URL — it causes the // 'signed in but landed on localhost' problem on the live site.
6. Make the login button call signInWithOAuth('custom:cargo-ac')
Replace your old login UI with one button. Calling signInWithOAuth with the provider you registered routes the user (via auth.cargo.ac) to one.cargo.ac, where they sign in or create an account; your backend then finishes the code exchange at the OAuth Callback URL and mints its OWN native session. SSO is automatic: if a Cargo session already exists the user is returned immediately. IMPORTANT: the `redirectTo` below is the in-app landing page after sign-in — make it a deterministic, allowlisted URL for this app (not window.location.origin in production). It is a DIFFERENT thing from the OAuth Callback URL in step 4.
await supabase.auth.signInWithOAuth({
provider: "custom:cargo-ac",
// landing page in YOUR app after sign-in (deterministic + allowlisted):
options: { redirectTo: "https://cargoworks.network" },
});
// Read the resulting native session like any normal client session:
supabase.auth.onAuthStateChange((_event, session) => { /* ... */ });7. 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).
8. Softly verify entitlement with the app_access claim — DO NOT hard-block yet
Provisioning is NOT authorization, but entitlement is enforced by Cargo at the hub consent screen — the spoke check is only a deferred, defense-in-depth backstop. Your exact key is the hub's registered app key (apps.key, snake_case), assigned by Cargo admin — for THIS app it is "cargoworks_network". It is NOT the hyphenated marketing/spoke name. Per-app reference: CargoWorks AI → `cargoworks_ai`, CargoWorks IO → `cargoworks_io`, Cargo Directory → `cargo_directory`, Cargo Page → `cargo_page`. CRITICAL: the "app_access" array is legitimately EMPTY for everyone today, because the hub has not provisioned org→app grants yet. If you hard sign-out on an empty/missing claim you will lock out EVERY user (this is exactly the "no access" bug). So treat empty/missing as ALLOW and only soft-warn/log for now — Cargo will announce when hard enforcement goes live. 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 access = claims.app_access ?? [];
// SOFT backstop — never block while the hub is still provisioning grants.
// An empty/absent array means 'not enforced yet', NOT 'denied'.
if (access.length > 0 && !access.includes("cargoworks_network")) {
// Entitlement IS being enforced and this user lacks it.
console.warn('user not entitled to this app');
// showRequestAccess(); // enable only once Cargo confirms enforcement
}
// Do NOT call supabase.auth.signOut() here today — it signs everyone out.9. 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 your Cargo Account". They are routed (via auth.cargo.ac, an invisible branded hop) to the Cargo Account user console at one.cargo.ac, where they sign in — or open one.cargo.ac/signup to create an account — and approve access once on one.cargo.ac/oauth/consent. Their backend then 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 back in the app.
① Open cargoworks.network → click "Continue with your Cargo Account"
supabase.auth.signInWithOAuth({ provider: "custom:cargo-ac" })
② Invisible hop through https://auth.cargo.ac/auth/v1/oauth/authorize
③ User lands on one.cargo.ac → /login (or /signup) → /oauth/consent
④ 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" → lands in app ✅
✅ users sign in on one.cargo.ac ❌ never the admin console
✅ spoke mints its own session ❌ no shared JWT secretWhat 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 (default): point your data-client at your RAW vendor backend host — send that exact callback to Cargo. A branded api.<your-app> host is an OPTIONAL later upgrade (native custom domain only — never a proxy)
- 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: Token endpoint auth method = client_secret_post (the backend defaults to _basic, which Cargo rejects as 'invalid client credentials')
- ADD: Site URL = your production host + Additional Redirect URLs (remove localhost, or you land on a dead localhost page after sign-in)
- ADD: a single button calling signInWithOAuth('custom:cargo-ac')
- ADD: a SOFT app_access claim check (snake_case apps.key, e.g. cargo_page) — log only; do NOT sign users out while the claim is still empty hub-side
- ADD: global single logout (signOut + Cargo global logout)
Pitfalls & FAQ
- Where does the "Continue with your Cargo Account" button take my users?
- To the Cargo Account user console at one.cargo.ac — NOT the admin console and NOT a page on your own site. one.cargo.ac/login is where they sign in, one.cargo.ac/signup (linked from the login page) is where they create a new account, and one.cargo.ac/oauth/consent is the one-time "Allow <App> to access your Cargo Account" screen. You do NOT hardcode or redirect to one.cargo.ac yourself — calling signInWithOAuth('custom:cargo-ac') routes there automatically through auth.cargo.ac (an invisible, branded redirect hop). After the user signs in and approves, they are sent back to your backend's OAuth callback and into your app, already signed in.
- What is the difference between the OAuth Callback URL and redirectTo?
- They are two different things. The OAuth Callback URL (step 4) is your backend's code-exchange endpoint — /auth/v1/callback on your data-backend host — and it is what Cargo allowlists. redirectTo (step 5) is the page inside YOUR app where the user lands after the session is minted; make it a deterministic, allowlisted URL (avoid window.location.origin in production). Cargo never sends the user to redirectTo directly; your backend does, after the callback completes.
- Why is the Callback URL grayed out and showing a vendor (e.g. 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. A raw vendor host here is the NORMAL, documented default (it is what the working CargoWorks AI pilot uses) — send that EXACT value to Cargo and it will be allowlisted. Do NOT route this through a branded proxy to 'fix' it: proxying the data backend breaks the external OAuth code exchange. Branding the API host with the backend's own native Custom Domains feature is an optional later upgrade once the raw-host flow is verified.
- 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.
- Sign-in fails with "invalid client credentials" (or the callback returns 500). What's wrong?
- Almost always the Token endpoint auth method. Cargo registers every confidential spoke client as client_secret_post, but the data backend's OIDC provider defaults to client_secret_basic — when the backend sends credentials via HTTP Basic the hub rejects them as 'invalid client credentials' (HTTP 400) and your /callback returns 500. Fix: Authentication → Providers → Custom OIDC custom:cargo-ac → set Token endpoint auth method = client_secret_post. While you're there, confirm the Client ID matches the one Cargo sent and repaste the Client Secret with no leading/trailing whitespace. You do NOT need to re-request the callback allowlist — Cargo already has it.
- After a successful sign-in the published site lands on localhost. Why?
- Your backend's Site URL is still http://localhost:3000 (the default for new projects). After the token exchange the backend redirects to {Site URL}/auth/callback, so production users get sent to a dead localhost page. Fix: Authentication → URL Configuration → set Site URL to your production host and remove localhost, then add Additional Redirect URLs (each /auth/callback) for the production domain, the *.lovable.app host, and the preview host. This is your backend's own setting, separate from the Cargo callback allowlist.
- 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?
- Entitlement is enforced by Cargo at the hub consent screen, so that is the real gate. The spoke-side app_access check is only a deferred backstop. IMPORTANT: the app_access array is empty for all users right now because the hub has not provisioned org→app grants yet, so do NOT hard sign-out on it — that is exactly what causes the 'no access' lock-out. Check it softly (log only) and treat empty/missing as allow until Cargo announces enforcement is live.
- What exact string is my app_access key — is it 'cw-ai' / my domain name?
- It is the hub's registered app key (apps.key, snake_case), assigned by Cargo admin — NOT the hyphenated marketing/spoke name and NOT 'cw-ai' unless you ARE CargoWorks AI. Per-app: CargoWorks AI → cargoworks_ai, CargoWorks IO → cargoworks_io, Cargo Directory → cargo_directory, Cargo Page → cargo_page. The guide's earlier 'cw-ai' example was just one app's value; use your own app's key.
- 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.
11. Test checklist
Confirm each item before flipping production traffic to cargo.ac.
- Your data-client points at your RAW vendor backend host (the default); any branded api.<your-app> host was done via the backend's native custom domain, never a proxy
- Users were confirmed to land on one.cargo.ac to sign in / create accounts (never the admin console)
- 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
- Token endpoint auth method = client_secret_post (NOT the default client_secret_basic)
- Site URL = your production host (localhost removed) + Additional Redirect URLs for prod, *.lovable.app and preview
- Confidential Client ID + Secret entered
- The EXACT Callback URL the form shows (normally your raw vendor host) was sent to Cargo and allowlisted
- 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 SOFT-checks the app_access claim (snake_case apps.key) and logs — but does NOT sign users out while the claim is still empty hub-side
- 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







