Connect your app to Unio.
Your users get the Unio button — every app they use, one click away — and arrive in your app already signed in. Three steps, and no new tables. Or hand the AI prompt to your coding agent and let it do them.
Fulfilling or paid from a store product? Unio also sends you signed webhooks for every purchase, renewal, cancellation and refund.
Unio says who is arriving. Your app keeps its own users.
Unio is an OpenID Connect provider. Your app stays in charge of its own accounts, passwords and two-step codes; Unio only tells it which of its users a person is. You store one id per user — nothing else.
- Step 1
Get your keys
Tell us your hosts and addresses. You get a client id and a secret.
Details ↓ - Step 2
Add the Unio button
One script tag or an iframe in your signed-in pages. It shows the person’s apps.
Details ↓ - Step 3
Add Sign in with Unio
Three small pages on your server: connect, launch, callback. Plus two calls back to us.
Details ↓
Connecting, once per person
- Your signed-in user opens the Unio button and clicks Connect Unio. A popup opens your connect page.
- Your server asks Unio to sign this person in, and sends the popup to Unio.
- Unio asks who they are — “Add YourApp to Ahmad’s Unio account?”, or sign in, or make a new account.
- Unio sends the popup back to your callback. Your server checks the answer and saves Unio’s id for this user.
- Your server sends the person’s details. The link is now live, and the popup closes itself.
Switching in, every time after
- In any Unio app, the person clicks your tile. A new tab opens your launch address.
- Your server asks Unio who this is. Unio answers at once, with no screen, for a person with a live link.
- Your callback finds the user by that id and signs them in — with your own two-step codes and rules.
Get your keys.
Send us:
- Your app's name and a square logo.
- Every host your users sign in on (for example app.example.com and example.com). Each becomes an allowed origin, and may frame the panel.
- The callback on each host, for example https://example.com/unio/callback.
- Your connect page path, for example /unio/connect.
- Your launch address, for example https://example.com/unio/launch.
- A separate set for staging. Staging is its own client.
We set up your client and send back a client id and a client secret. The secret is shown once, so put it straight into your server’s secret settings. Your hosts are allowed to show the Unio panel from then on.
Add Sign in with Unio.
Standard OpenID Connect, with pushed requests and PKCE required. Every call to Unio comes from your server, authenticated with HTTP Basic (client id and secret). The browser never sees the secret.
| What | Address | Use |
|---|---|---|
| Discovery | GET https://uniodental.com/api/oidc/.well-known/openid-configuration | Read once and cache. Its issuer must equal UNIO_ISSUER exactly. |
| Signing keys | GET https://uniodental.com/api/oidc/jwks | Verifies id_tokens. Cache; refetch once on an unknown kid. |
| Pushed request | POST https://uniodental.com/api/oidc/request | Starts every connect and launch, from your server. |
| Authorize | GET https://uniodental.com/api/oidc/auth?client_id=<client id>&request_uri=<request_uri> | Where you send the browser next. |
| Token | POST https://uniodental.com/api/oidc/token | Trades the code for an id_token, from your server. |
| Profile | PUT https://uniodental.com/api/partner/v1/profile | After every successful callback. The first one confirms the link. |
| Disconnect | POST https://uniodental.com/api/partner/v1/disconnect | When your user removes Unio, or is deleted. |
| Panel | GET https://uniodental.com/embed?partner=<client id>&host=<page origin>&pu=<user hash> | The iframe inside your Unio button. |
| Popup finish | GET https://uniodental.com/embed/done | Where a connect started from the panel ends: add ?error=<reason> when it failed. |
The three pages you build
- Connect (for example
/unio/connect). A GET shows a small page whose form POSTs back with your CSRF protection; it may submit itself only when the visitor came fromhttps://uniodental.com. The POST needs a signed-in, active user with a verified email, then makes the pushed request withunio_intent=connect. - Launch (your launch address). Unio opens it with
?iss=https://uniodental.com/api/oidc. Accept GET and POST, and turn a POST into a GET with a 303 before touching the session. Checkissexactly, keeptarget_link_urionly if it is a path on your own host, then make the pushed request withunio_intent=launch. - Callback (for example
/unio/callback). Check state andiss, trade the code for an id_token, verify it, and then connect or sign in. Every branch ends in a redirect.
1. Start: the pushed request
Keep state, nonce and the PKCE verifier in your server-side session, single use, for 10 minutes. The unio_* fields are only accepted here, never from a browser address.
POST https://uniodental.com/api/oidc/request
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
response_type=code
client_id=<client id>
redirect_uri=<your callback>
scope=openid
state=<random>
nonce=<random>
code_challenge=<S256 of the verifier>
code_challenge_method=S256
unio_intent=connect | launch
unio_partner_user_id=<your user id, as text>
login_hint=<their email>
unio_email_verified=true | false
unio_given_name=<first name>
unio_family_name=<last name>
unio_partner_company_id=<company open on your side, as text>
→ 201 { "request_uri": "urn:ietf:params:oauth:request_uri:…", "expires_in": 60 }
Then send the browser to:
https://uniodental.com/api/oidc/auth?client_id=<client id>&request_uri=<request_uri>| Field | Value | Notes |
|---|---|---|
response_type | code | |
client_id | <client id> | Also the Basic auth user. |
redirect_uri | <your callback> | Exactly one of the addresses you registered, on the host the person is using. |
scope | openid | Nothing else is needed. |
state | <random> | Kept in your session; checked on the callback. |
nonce | <random> | Kept in your session; must equal the id_token nonce. |
code_challenge | <S256 of the verifier> | PKCE is required. |
code_challenge_method | S256 | |
unio_intent | connect | launch | Connect attaches your user; launch signs an already-linked one in. |
unio_partner_user_id | <your user id, as text> | Connect only. Who is connecting on your side. |
login_hint | <their email> | Connect only. Shown and pre-filled on Unio's screens. |
unio_email_verified | true | false | Connect only. Exactly whether your user proved this address. |
unio_given_name | <first name> | Connect only. Used if they make a new Unio account. |
unio_family_name | <last name> | Connect only. |
unio_partner_company_id | <company open on your side, as text> | Connect only. Leave it out for a personal account. When that company is linked to a Unio practice, the sign-in screen names the practice. |
2. The callback
A plain GET redirect back to you — never a form post, so your session cookie arrives.
GET https://yourapp.com/unio/callback?code=…&state=…&iss=https%3A%2F%2Funiodental.com%2Fapi%2Foidc
or, when it did not work:
GET https://yourapp.com/unio/callback?error=access_denied&error_description=user_cancelled&state=…&iss=…| error_description | Meaning | What to do |
|---|---|---|
user_cancelled | The person closed or declined. | Say nothing went wrong; offer to try again. |
unio_not_connected | Launch, but this Unio person has no confirmed link to your app. | Ask them to sign in to your app and connect Unio from the Unio button. |
impersonation_not_allowed | A Unio admin is viewing as this person. | Refuse. Nothing is attached or signed in. |
partner_user_linked_elsewhere | Your user is already linked to a different Unio person. | Tell them this account is connected to another Unio account. |
unio_account_linked_elsewhere | This Unio person is already linked to a different user of your app. | Tell them to disconnect Unio from the other account first. |
3. The token, and the only claim you use
POST https://uniodental.com/api/oidc/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
code=<code>
redirect_uri=<the same callback>
code_verifier=<the PKCE verifier>
→ 200 { "id_token": "…", "access_token": "…", "token_type": "Bearer", "expires_in": 600 }Verify the id_token: RS256 only, a kid that is in the signing keys, iss equal to https://uniodental.com/api/oidc, aud equal to your client id, exp in the future, and nonce equal to the one you stored. Then use only sub: the stable id of this Unio person for your app. Keep no Unio tokens.
4. Connect or sign in
- Connect: the signed-in user must be the one who started. Refuse a
subthat is linked to another of your users, and a user who is linked to anothersub. Save the link, then send the profile. If that fails, delete the link you just made. - Launch: find the user linked to this
sub. Refuse inactive users, admins and impersonation. If someone else is signed in, ask “Switch to Ahmad?” or “Stay as Sara”. Otherwise reset the session and sign them in — or hand them to your two-step code step, exactly where a correct password would.
5. Send the person’s details
After every successful callback. The first one makes the link live in Unio. Every key is optional: one you leave out keeps its value, and null clears it.
PUT https://uniodental.com/api/partner/v1/profile
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/json
{
"sub": "<id_token sub>",
"partner_user_id": "<your user id, as text>",
"profile": {
"first_name": "Ahmad",
"last_name": "Khan",
"email": "dr.ahmad@example.com",
"email_verified": true,
"phone": "+1 555 0100",
"job_title": "Owner dentist",
"company_name": "Bright Smiles Dental",
"company_type": "seller",
"address": {
"street": "1 Main St",
"city": "Naples",
"state": "FL",
"zip": "34102",
"country": "US",
"formatted": "1 Main St, Naples, FL 34102"
},
"companies": [
{
"id": "<your company id>",
"name": "Bright Smiles Dental",
"leader": true
}
]
}
}
→ 204 · 404 {"error":"unknown_subject"} · 409 {"error":"partner_user_mismatch"}6. Disconnect
When your user removes Unio in your settings, or is deleted.
POST https://uniodental.com/api/partner/v1/disconnect
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/json
{ "sub": "<id_token sub>" }
→ 204 (always; safe to repeat)7. Close the popup
A connect started from the panel ends by sending the popup to https://uniodental.com/embed/done, which hands the panel its sign-in and closes. When it did not work, add ?error=<reason> and the popup says why. It knows:
partner_user_linked_elsewhere, unio_account_linked_elsewhere, impersonation_not_allowed, user_cancelled, linked_elsewhere, already_connected, impersonating, inactive, not_allowed, switched_off, email_unverified, expired, session_changed, no_workspace, host_not_allowed, rate_limited, profile_failed, unavailable
One row per connected user, in a table you already have.
Store the link in your existing sign-in identities table (the one that holds Google or other logins): provider unio, uid = sub. Two unique indexes keep it one Unio person to one of your users, both ways:
-- The only database change. Limited to Unio rows, so your other providers are untouched.
CREATE UNIQUE INDEX CONCURRENTLY index_identities_on_unio_uid
ON identities (provider, uid) WHERE provider = 'unio';
CREATE UNIQUE INDEX CONCURRENTLY index_identities_on_unio_owner
ON identities (user_id) WHERE provider = 'unio';What keeps your users’ accounts safe.
Each one is a way we have seen an integration go wrong.
- Only the id_token sub identifies a Unio person. Never find, link or sign in anyone by matching an email: that hands an account to whoever registered the address first.
- Connect needs a signed-in, active user whose email is verified. An unverified account may have been set up by someone else with the owner's address.
- Never connect or switch while someone is impersonating a user. An admin viewing as someone must not act as them.
- Admins, and anyone with admin permissions, sign in the normal way. A second way into a powerful account is a second thing to protect.
- Keep your two-step login. Unio replaces the password, never the code. Send no profile until the code is entered.
- Never silently replace whoever is signed in. On a shared front-desk computer, ask "Switch to Ahmad?" or "Stay as Sara".
- One Unio person to one of your users, both ways. Hold it with two unique indexes, not only with checks.
- State, nonce and code verifier stay server side, single use, 10 minutes. They are what stops a forged or replayed callback.
- Every callback branch redirects. Never render a page while the code is in the address bar, and never log the code.
- Check iss on the launch and on the callback. It must equal UNIO_ISSUER exactly; a target_link_uri must be a path on your own host.
- Keep no Unio tokens. You need the sub, nothing else. The access token is unused.
- The client secret never reaches a browser. Every call to Unio is made by your server.
- Rate limit the two start actions. Each makes a server call to Unio from your address, and Unio limits per address, so one script could stop it for all your users.
- Do not route this through your social-login library. Those callbacks usually create users and link by email. Write dedicated handlers.
- Send the whole companies list, and send it again when it changes. Unio adds people to the practice a company is linked to. Resend the profile when someone joins or leaves a company or their role changes, and mark your own staff as not a leader.
- A target is a path on your own host, checked after parsing. Refuse any control character, space or backslash in it (raw or percent-encoded), then parse it against your own origin: the result must still be your origin, and you redirect to its re-serialized path and query, never the raw string. Anything else is dropped and the person goes home: an open redirect hands a signed-in session to whoever wrote the link.
- Your /unio pages cannot be framed. Send frame-ancestors 'none' (or X-Frame-Options: DENY) on the connect, launch and "Switch to?" pages, so no other site can overlay them and trick a click.
- A changed email is unproven again. Until the user confirms the new address, refuse connect (email_unverified) and send unio_email_verified=false and email_verified false; resend the profile with true once they confirm it.
- On a launch, a 409 from the profile call signs the person out again. partner_user_mismatch means your stored link and Unio disagree about who this is. Sign out, delete your stored link without calling disconnect, and ask them to connect Unio again. Any other profile failure is logged, not shown.
- Allow at most 60 seconds of clock skew. On exp and iat, and no more: a wider window keeps a stolen id_token useful.
- The secret can be replaced without a deploy. Read it from your settings. When Unio rotates it, the old one stops at once, so put the new one in straight away.
Five settings, and all five or nothing.
| Setting | Example | Notes |
|---|---|---|
UNIO_ISSUER | https://uniodental.com/api/oidc | Compared byte for byte with iss. |
UNIO_ORIGIN | https://uniodental.com | Where the partner API and the panel live. |
UNIO_CLIENT_ID | yourapp | From Unio. Also your ?partner= value. |
UNIO_CLIENT_SECRET | <shown once> | Secret. Server side only. |
UNIO_PANEL_KEY | <any long random string> | Yours alone: keys the user hash (pu). Never sent to Unio. |
With any of them missing, turn the feature off: no button, and every Unio page returns 404.
Testing
- In automated tests, fake Unio at the HTTP layer and sign test id_tokens with your own RSA key.
- Staging gets its own client and secret from us. Test connect, switching in, and disconnect there before production.
- Against a local Unio: set
UNIO_ISSUER=http://localhost:3000/api/oidcandUNIO_ORIGIN=http://localhost:3000, and serve your app overhttp://too (Safari refusesSecurecookies over plain HTTP).
Let your coding agent do the integration.
Copy this into Claude Code, Cursor or any coding agent, in your app’s repository. It works for any stack: the agent reads your codebase first, builds steps 2 and 3 the way your app already does things, writes the tests, and tells you what to send us.
You are adding "Sign in with Unio" to this application.
Unio (https://uniodental.com) is an OpenID Connect provider for dental software. When you are done:
- a signed-in user of this app can connect their account to Unio from a floating Unio button, and
- a person signed in to Unio can open this app from Unio's app switcher and be signed in with one click.
This app keeps its own users and its own login. Unio only tells it WHICH of its users is arriving.
Work in the order below. Do not skip step 1, and do not change any name or value in step 2.
=== STEP 1. READ THE CODEBASE FIRST (write nothing yet) ===
Find and write down:
- Language, framework and version; how routes, handlers, background jobs, migrations and tests are written here.
- The authentication system: how a user is signed in, signed out, and how the session is reset.
- The user model: the id type; how "active / disabled" is stored; how admins and any admin permissions or roles are stored; how "email verified" is stored and what sets it.
- Two-step login (2FA / OTP), if any: the exact point where a correct password hands over to the code step.
- Impersonation ("log in as user"), if any, and how to tell it is happening.
- An existing table for external sign-in identities (for example connected_accounts, identities, oauth_accounts, social_accounts). Reuse it. Only if none exists, add the smallest table possible: owner, provider, uid, timestamps.
- The layouts used by signed-in pages, and the app's Content-Security-Policy.
- The HTTP client and the JWT / JWKS library already in the dependency tree. Prefer them over adding new ones.
- Cookies that outlive a session (last workspace, pending invites) and where sign-out clears them.
Report what you found before changing anything. If the app cannot tell whether a user's email is verified, or cannot tell admins apart, stop and ask.
=== STEP 2. THE CONTRACT ===
Every server-to-server call uses HTTP Basic auth with client id and client secret, over HTTPS.
Issuer: https://uniodental.com/api/oidc
Endpoints:
- Discovery: GET https://uniodental.com/api/oidc/.well-known/openid-configuration (auth: none). Read once and cache. Its issuer must equal UNIO_ISSUER exactly.
- Signing keys: GET https://uniodental.com/api/oidc/jwks (auth: none). Verifies id_tokens. Cache; refetch once on an unknown kid.
- Pushed request: POST https://uniodental.com/api/oidc/request (auth: Basic). Starts every connect and launch, from your server.
- Authorize: GET https://uniodental.com/api/oidc/auth?client_id=<client id>&request_uri=<request_uri> (auth: none). Where you send the browser next.
- Token: POST https://uniodental.com/api/oidc/token (auth: Basic). Trades the code for an id_token, from your server.
- Profile: PUT https://uniodental.com/api/partner/v1/profile (auth: Basic). After every successful callback. The first one confirms the link.
- Disconnect: POST https://uniodental.com/api/partner/v1/disconnect (auth: Basic). When your user removes Unio, or is deleted.
- Panel: GET https://uniodental.com/embed?partner=<client id>&host=<page origin>&pu=<user hash> (auth: none). The iframe inside your Unio button.
- Popup finish: GET https://uniodental.com/embed/done (auth: none). Where a connect started from the panel ends: add ?error=<reason> when it failed.
Pushed request fields (form-encoded POST to https://uniodental.com/api/oidc/request; the reply is 201 with request_uri and expires_in):
- response_type = code
- client_id = <client id>. Also the Basic auth user.
- redirect_uri = <your callback>. Exactly one of the addresses you registered, on the host the person is using.
- scope = openid. Nothing else is needed.
- state = <random>. Kept in your session; checked on the callback.
- nonce = <random>. Kept in your session; must equal the id_token nonce.
- code_challenge = <S256 of the verifier>. PKCE is required.
- code_challenge_method = S256
- unio_intent = connect | launch. Connect attaches your user; launch signs an already-linked one in.
- unio_partner_user_id = <your user id, as text> (connect only). Who is connecting on your side.
- login_hint = <their email> (connect only). Shown and pre-filled on Unio's screens.
- unio_email_verified = true | false (connect only). Exactly whether your user proved this address.
- unio_given_name = <first name> (connect only). Used if they make a new Unio account.
- unio_family_name = <last name> (connect only)
- unio_partner_company_id = <company open on your side, as text> (connect only). Leave it out for a personal account. When that company is linked to a Unio practice, the sign-in screen names the practice.
A request that is not pushed is refused. The unio_* fields are only accepted in the pushed request, never in a browser URL.
After the pushed request, send the browser to https://uniodental.com/api/oidc/auth?client_id=<client id>&request_uri=<request_uri>.
Callback: Unio sends the browser back with a plain GET to your redirect_uri, with code, state and iss. Never form_post.
On failure it sends error=access_denied&error_description=<reason>&state=<state>&iss=<issuer>. The reasons:
- user_cancelled: The person closed or declined. Say nothing went wrong; offer to try again.
- unio_not_connected: Launch, but this Unio person has no confirmed link to your app. Ask them to sign in to your app and connect Unio from the Unio button.
- impersonation_not_allowed: A Unio admin is viewing as this person. Refuse. Nothing is attached or signed in.
- partner_user_linked_elsewhere: Your user is already linked to a different Unio person. Tell them this account is connected to another Unio account.
- unio_account_linked_elsewhere: This Unio person is already linked to a different user of your app. Tell them to disconnect Unio from the other account first.
Token: POST https://uniodental.com/api/oidc/token with grant_type=authorization_code, code, redirect_uri, code_verifier. The reply has an id_token.
Verify the id_token: RS256 only (refuse "none" and every other algorithm), kid required, signature from the signing keys, iss equals the issuer, aud equals your client id (if aud has several values, azp must equal your client id), exp in the future and iat not in the future (at most 60 seconds of clock skew either way), nonce equals the one you stored. Then use ONLY its sub. sub is the stable id of this Unio person for your app. Ignore every other claim.
Profile: PUT https://uniodental.com/api/partner/v1/profile with a JSON body like this (every profile key is optional; a key left out keeps its value; null clears it; company_type is buyer, seller or broker):
{
"sub": "<id_token sub>",
"partner_user_id": "<your user id, as text>",
"profile": {
"first_name": "Ahmad",
"last_name": "Khan",
"email": "dr.ahmad@example.com",
"email_verified": true,
"phone": "+1 555 0100",
"job_title": "Owner dentist",
"company_name": "Bright Smiles Dental",
"company_type": "seller",
"address": {
"street": "1 Main St",
"city": "Naples",
"state": "FL",
"zip": "34102",
"country": "US",
"formatted": "1 Main St, Naples, FL 34102"
},
"companies": [
{
"id": "<your company id>",
"name": "Bright Smiles Dental",
"leader": true
}
]
}
}
Replies: 204 saved; 404 {"error":"unknown_subject"}; 409 {"error":"partner_user_mismatch"}. The FIRST successful profile call is what makes the link live in Unio.
Disconnect: POST https://uniodental.com/api/partner/v1/disconnect with {"sub": "<sub>"}. Always 204, safe to repeat.
Launch: Unio opens <your launch address>?iss=<issuer> in a new tab (target_link_uri is optional). Accept GET and POST.
Panel: the Unio button is an iframe at https://uniodental.com/embed?partner=<client id>&host=<the page's origin>&pu=<user hash>.
pu is the first 32 hex characters of HMAC-SHA256(UNIO_PANEL_KEY, your user id). Allowed characters: A-Z a-z 0-9 _ - (up to 64). Never the raw id or an email.
The panel's "Connect Unio" opens a popup at <host><your connect path>?from=embed. A connect started there must END by sending the popup to https://uniodental.com/embed/done (success) or https://uniodental.com/embed/done?error=<reason> (failure). Reasons the popup understands: partner_user_linked_elsewhere, unio_account_linked_elsewhere, impersonation_not_allowed, user_cancelled, linked_elsewhere, already_connected, impersonating, inactive, not_allowed, switched_off, email_unverified, expired, session_changed, no_workspace, host_not_allowed, rate_limited, profile_failed, unavailable. Any other reason shows a general message.
=== STEP 3. WHAT TO BUILD ===
1. Settings, read from the environment or the app's secret store, never committed:
- UNIO_ISSUER (e.g. https://uniodental.com/api/oidc): Compared byte for byte with iss.
- UNIO_ORIGIN (e.g. https://uniodental.com): Where the partner API and the panel live.
- UNIO_CLIENT_ID (e.g. yourapp): From Unio. Also your ?partner= value.
- UNIO_CLIENT_SECRET (e.g. <shown once>): Secret. Server side only.
- UNIO_PANEL_KEY (e.g. <any long random string>): Yours alone: keys the user hash (pu). Never sent to Unio.
When any of them is missing, the feature is off: every /unio route returns 404 and no button renders.
2. A Unio client module (server side). Discovery cached about an hour: its issuer must equal UNIO_ISSUER exactly and every endpoint must be on the issuer's origin, so the secret can never be sent elsewhere. Signing keys cached; refetch once on an unknown kid, and only while verifying a token that came from the token endpoint. Calls: pushed request, token exchange, id_token verification, profile, disconnect. Timeouts of about 5 seconds, never follow redirects, never log response bodies, the secret or the code.
3. The pending sign-in: store state, nonce, code_verifier, mode (connect or launch), redirect_uri, whether it came from the panel, the user id (connect) and the target (launch) in the server-side session, bound to this browser, single use, expiring after 10 minutes.
4. Routes (keep these paths unless the app has a strong convention):
- GET /unio/connect: a small page whose form POSTs to /unio/connect with the app's CSRF protection. It may submit itself only when the Referer's origin is https://uniodental.com; otherwise it shows a Continue button.
- POST /unio/connect: requires a signed-in, active user with a verified email, not impersonated, not an admin. Store the pending sign-in, make the pushed request with unio_intent=connect and the connect-only fields, redirect the browser to the authorize address. Rate limited per IP (for example 10 per 3 minutes).
- GET and POST /unio/launch: a POST is redirected with 303 to the GET with the same query BEFORE anything touches the session or flash (a cross-site POST arrives without your session cookie, and a new session cookie would sign the current user out). The GET checks iss equals UNIO_ISSUER exactly, keeps target_link_uri only if it is a path on this host (it starts with exactly one "/", not "//" or "/\"; it holds no control character, space or backslash, raw or percent-encoded; and once parsed against this origin it is still on this origin, in which case you keep the re-serialized path and query, never the raw string; otherwise it is dropped), and shows a small page whose CSRF-protected form POSTs to /unio/launch/start (same Referer rule for submitting itself).
- POST /unio/launch/start: refuse if impersonating; store the pending sign-in; pushed request with unio_intent=launch and no connect fields; redirect to the authorize address. Rate limited per IP.
- GET /unio/callback: take the pending sign-in out of the session (missing, expired or state mismatch: fail); iss must equal UNIO_ISSUER; if error is present, map error_description; else exchange the code and verify the id_token. Every branch ends in a redirect. Then:
Connect: the signed-in user must still be the one who started (else fail with session_changed). If the sub is already linked to a different user: fail with linked_elsewhere. If this user is linked to a different sub: fail with already_connected. Otherwise save the link (provider "unio", uid = sub) in the identity table. Then send the profile. If the profile call fails, DELETE the row you just created without calling disconnect, and fail with profile_failed. On success: from the panel, redirect the popup to https://uniodental.com/embed/done; otherwise back to the settings page with a notice.
Launch: find the user linked to this sub (none: explain they must connect first). Refuse inactive users, admins and anyone with admin permissions, and impersonation. If a DIFFERENT user is signed in in this browser, store a 5-minute pending switch and ask "Switch to <name>?" or "Stay as <name>"; on "switch", re-check everything before acting. If the same user is already signed in, go to the target. Otherwise sign out, reset the session, clear the cookies that outlive a session, then: with two-step login on, hand over to the existing code step exactly where a correct password would (do not sign in, do not send the profile yet); without it, sign in, send the profile (a 409 partner_user_mismatch signs them out again, deletes your stored link without calling disconnect and asks them to connect Unio again; any other failure is logged, not shown), and go to the target or home.
- A connect started from the panel reports every failure by redirecting the popup to https://uniodental.com/embed/done?error=<reason>, never by stranding it on your page.
- Every /unio page you render (connect, launch, the "Switch to?" question) sends Content-Security-Policy frame-ancestors 'none' (or X-Frame-Options: DENY), so no other site can frame it.
5. Linking storage: in the identity table, provider "unio" and uid = sub. Add two unique indexes limited to provider "unio": one on (provider, uid) and one on the owner. Build them without locking the table if the database supports it. Add no other table or column. Catch the unique-violation error and treat it as linked_elsewhere.
6. Disconnect: when a user removes the Unio link in your settings, or the user is deleted, POST disconnect with the sub from a background job with retries.
Email change: when a linked user changes their email, mark it unverified until they confirm it (connect is refused meanwhile with email_unverified), resend the profile with email_verified false from a background job, and resend it with true once they confirm.
7. The Unio button: a small floating button (bottom-right) that opens a panel containing the iframe from step 2, inside a container that survives your page navigation (for example data-turbo-permanent). Show it only when signed in, active, not impersonating and configured. Make sure it never covers a fixed bottom action such as a wizard's Next button. Alternatively load https://uniodental.com/widget.js with data-partner="<client id>" and data-partner-user="<user hash>". Allow https://uniodental.com in your Content-Security-Policy under frame-src (and script-src if you use widget.js).
8. Tests, with Unio faked at the HTTP layer (discovery, signing keys, pushed request, token, profile, disconnect; sign test id_tokens with your own RSA key):
- connect happy path; the pushed request carries every field above; no token is stored.
- tampered, reused, expired and other-browser state are refused before any code exchange.
- wrong nonce, wrong iss, wrong aud, wrong algorithm, expired token are refused.
- sub linked to another user; user linked to another sub; failed profile call removes the new link and sends no disconnect.
- launch: linked active user signed in and sent to the target; unknown sub; inactive; admin; admin permission; impersonating; two-step user sent to the code with no profile sent; someone else signed in is asked; a target on another host is ignored; wrong iss refused; a POSTed launch is bounced to a GET without a new session cookie.
- unverified email cannot connect; the button shows only when it should; disconnect job calls Unio.
- a launch target of //evil.test, /\evil.test, /%09/evil.test, a path with a raw tab or a backslash, https://evil.test or javascript:alert(1) is ignored.
- a launch whose profile call answers 409 partner_user_mismatch signs the person out and deletes the stored link, with no disconnect call.
- an id_token whose exp passed more than 60 seconds ago, or whose iat is more than 60 seconds ahead, is refused.
- the connect, launch and switch pages send frame-ancestors 'none' (or X-Frame-Options: DENY).
- an email change marks the email unverified and resends the profile with email_verified false.
- every route 404s when unconfigured.
=== RULES ===
- Only the id_token sub identifies a Unio person. Never find, link or sign in anyone by matching an email: that hands an account to whoever registered the address first.
- Connect needs a signed-in, active user whose email is verified. An unverified account may have been set up by someone else with the owner's address.
- Never connect or switch while someone is impersonating a user. An admin viewing as someone must not act as them.
- Admins, and anyone with admin permissions, sign in the normal way. A second way into a powerful account is a second thing to protect.
- Keep your two-step login. Unio replaces the password, never the code. Send no profile until the code is entered.
- Never silently replace whoever is signed in. On a shared front-desk computer, ask "Switch to Ahmad?" or "Stay as Sara".
- One Unio person to one of your users, both ways. Hold it with two unique indexes, not only with checks.
- State, nonce and code verifier stay server side, single use, 10 minutes. They are what stops a forged or replayed callback.
- Every callback branch redirects. Never render a page while the code is in the address bar, and never log the code.
- Check iss on the launch and on the callback. It must equal UNIO_ISSUER exactly; a target_link_uri must be a path on your own host.
- Keep no Unio tokens. You need the sub, nothing else. The access token is unused.
- The client secret never reaches a browser. Every call to Unio is made by your server.
- Rate limit the two start actions. Each makes a server call to Unio from your address, and Unio limits per address, so one script could stop it for all your users.
- Do not route this through your social-login library. Those callbacks usually create users and link by email. Write dedicated handlers.
- Send the whole companies list, and send it again when it changes. Unio adds people to the practice a company is linked to. Resend the profile when someone joins or leaves a company or their role changes, and mark your own staff as not a leader.
- A target is a path on your own host, checked after parsing. Refuse any control character, space or backslash in it (raw or percent-encoded), then parse it against your own origin: the result must still be your origin, and you redirect to its re-serialized path and query, never the raw string. Anything else is dropped and the person goes home: an open redirect hands a signed-in session to whoever wrote the link.
- Your /unio pages cannot be framed. Send frame-ancestors 'none' (or X-Frame-Options: DENY) on the connect, launch and "Switch to?" pages, so no other site can overlay them and trick a click.
- A changed email is unproven again. Until the user confirms the new address, refuse connect (email_unverified) and send unio_email_verified=false and email_verified false; resend the profile with true once they confirm it.
- On a launch, a 409 from the profile call signs the person out again. partner_user_mismatch means your stored link and Unio disagree about who this is. Sign out, delete your stored link without calling disconnect, and ask them to connect Unio again. Any other profile failure is logged, not shown.
- Allow at most 60 seconds of clock skew. On exp and iat, and no more: a wider window keeps a stolen id_token useful.
- The secret can be replaced without a deploy. Read it from your settings. When Unio rotates it, the old one stops at once, so put the new one in straight away.
=== BEFORE PRODUCTION: WHAT UNIO CHECKS ===
Unio turns your production client on only after it has run these against your STAGING. Make each one an automated test where you can, and walk the rest by hand before you report:
1. Settings empty. Expected: Every /unio route answers 404 and no Unio button shows.
2. Connect from the Unio button, then open your app from a Unio tile, then disconnect in your settings. Expected: Each works; after disconnect the tile is gone from Unio.
3. The same callback address opened a second time, or in another browser. Expected: Refused, nobody signed in.
4. A launch with iss set to another address. Expected: Refused.
5. A launch whose target is //evil.test, /\evil.test, /%09/evil.test, a path with a raw tab, https://evil.test or javascript:alert(1). Expected: The target is ignored and the person lands on your home page.
6. A launch POSTed from another site while someone is signed in. Expected: Bounced to a GET; the signed-in person is not signed out.
7. A launch for an admin, an inactive user, and while impersonating. Expected: Refused each time; nobody is signed in.
8. A launch for a user with two-step login. Expected: The code step, not signed in until the code is entered.
9. A launch while someone else is signed in in that browser. Expected: Asked "Switch to <name>?" or "Stay as <name>"; nothing changes before the answer.
10. Connect with an unverified email, and while impersonating. Expected: Refused (email_unverified, impersonating).
11. A second user of your app connects the same Unio person. Expected: Refused (linked_elsewhere).
12. Your connect, launch and "Switch to?" pages loaded inside another site's iframe. Expected: Blocked by the browser.
13. The panel iframe address and your page source. Expected: pu is a hash, never a raw id or an email; the client secret appears nowhere in a browser.
14. Connect starts, and launch starts, from one address beyond your rate limit. Expected: The later ones are refused, for both.
15. A user changes their email. Expected: Connect is refused until they confirm it, and the next profile Unio receives says email_verified false.
16. A callback with iss missing, or set to another address. Expected: Refused before any code exchange.
17. Your tests: id_tokens with alg none, HS256 signed with Unio's public key, the wrong aud or azp, expired, a missing or wrong nonce, and an unknown kid. Expected: All refused; the unknown kid refetches the signing keys once, no more.
18. A connect started as one user, finished after signing in as another. Expected: Refused (session_changed).
19. Connect by an admin, or anyone with admin permissions. Expected: Refused (not_allowed).
20. A launch whose profile call Unio answers 409 partner_user_mismatch. Expected: The person is signed out again and asked to connect Unio again.
21. A token exchange forced to fail. Expected: Nothing logged or sent to error tracking carries the Authorization header, the secret or the code.
22. Two connect starts. Expected: Each sends a fresh code_challenge (S256) from a new random verifier, a new state and a new nonce.
=== STEP 4. WHEN YOU ARE DONE, REPORT ===
- Every file you added or changed, and the migration.
- The settings to fill in, per environment.
- What to send Unio to get a client:
- Your app's name and a square logo.
- Every host your users sign in on (for example app.example.com and example.com). Each becomes an allowed origin, and may frame the panel.
- The callback on each host, for example https://example.com/unio/callback.
- Your connect page path, for example /unio/connect.
- Your launch address, for example https://example.com/unio/launch.
- A separate set for staging. Staging is its own client.
- Each check under BEFORE PRODUCTION: the test that covers it, or how you checked it by hand.
- Anything from step 1 that did not fit this plan, and what you did about it.
Review what it builds as you would any change to your sign-in code.
Before you switch it on.
Unio turns your production client on only after running these checks against your staging. Pass them first: each one is a way an integration goes wrong.
| We try | You must |
|---|---|
| Settings empty. | Every /unio route answers 404 and no Unio button shows. |
| Connect from the Unio button, then open your app from a Unio tile, then disconnect in your settings. | Each works; after disconnect the tile is gone from Unio. |
| The same callback address opened a second time, or in another browser. | Refused, nobody signed in. |
| A launch with iss set to another address. | Refused. |
| A launch whose target is //evil.test, /\evil.test, /%09/evil.test, a path with a raw tab, https://evil.test or javascript:alert(1). | The target is ignored and the person lands on your home page. |
| A launch POSTed from another site while someone is signed in. | Bounced to a GET; the signed-in person is not signed out. |
| A launch for an admin, an inactive user, and while impersonating. | Refused each time; nobody is signed in. |
| A launch for a user with two-step login. | The code step, not signed in until the code is entered. |
| A launch while someone else is signed in in that browser. | Asked "Switch to <name>?" or "Stay as <name>"; nothing changes before the answer. |
| Connect with an unverified email, and while impersonating. | Refused (email_unverified, impersonating). |
| A second user of your app connects the same Unio person. | Refused (linked_elsewhere). |
| Your connect, launch and "Switch to?" pages loaded inside another site's iframe. | Blocked by the browser. |
| The panel iframe address and your page source. | pu is a hash, never a raw id or an email; the client secret appears nowhere in a browser. |
| Connect starts, and launch starts, from one address beyond your rate limit. | The later ones are refused, for both. |
| A user changes their email. | Connect is refused until they confirm it, and the next profile Unio receives says email_verified false. |
| A callback with iss missing, or set to another address. | Refused before any code exchange. |
| Your tests: id_tokens with alg none, HS256 signed with Unio's public key, the wrong aud or azp, expired, a missing or wrong nonce, and an unknown kid. | All refused; the unknown kid refetches the signing keys once, no more. |
| A connect started as one user, finished after signing in as another. | Refused (session_changed). |
| Connect by an admin, or anyone with admin permissions. | Refused (not_allowed). |
| A launch whose profile call Unio answers 409 partner_user_mismatch. | The person is signed out again and asked to connect Unio again. |
| A token exchange forced to fail. | Nothing logged or sent to error tracking carries the Authorization header, the secret or the code. |
| Two connect starts. | Each sends a fresh code_challenge (S256) from a new random verifier, a new state and a new nonce. |
- Production has its own client, the secret is in production’s settings, and every host is registered.
- Your Content-Security-Policy allows
https://uniodental.com.
Stable URLs for the Unio mark.
The wordmark, white, on the brand navy square. Hotlink these directly — they will not move, and they are never behind an auth or pre-launch redirect.
https://uniodental.com/icon.svg— the canonical app icon. Square and full-bleed, with no corner rounding, because most surfaces round the tile themselves. Also at/icon.png(512×512),/apple-touch-icon.png(180×180) and/favicon.ico.https://uniodental.com/icon-rounded.svg— the same mark with the rounding applied, for surfaces that drop it in as-is. Use it only if you are not setting your ownborder-radius. Also at/icon-rounded.png.https://uniodental.com/logo.svg— the wordmark on its own, transparent, withfill="currentColor"so it inherits your text color.
Ready to connect your app?
Send us your hosts and addresses from step 1, and we’ll set up your client.
Talk to usBuilding an app for the store instead? Browse the directory →