Site sign-in
How visitors sign in on your public site, with a Median account, your own app, or an OpenID Connect provider.
Signing in lets visitors post, vote, comment and follow on the feedback board, and keeps their support chat across devices. The help center never asks.
| Method | Visitors sign in with | Setup | Same customer as the widget |
|---|---|---|---|
| Median accounts | A free Median account | None | No. Matched by email address |
| Your app (recommended) | Their account in your product | One route in your app | Yes |
| OpenID Connect | Your identity provider, like Auth0 or Okta | The provider's details | When your widget signs the same sub |
Pick one under Site → Sign-in. Admins and owners only. Switching signs out everyone who signed in another way.
Median accounts
The default. There is nothing to set up.
| Fact | Detail |
|---|---|
| What visitors see | Sign in to Acme on median.sh, with Continue and Cancel |
| No account yet | They make one on the same page. It is free |
| What you get | Their name, email and picture |
| Chat history | Joins whatever that email address sent you before, like email to your support address |
Your app
Visitors sign in with the account they already have in your product. Their site chat and their widget chat are one history.
import { currentUser } from "@clerk/nextjs/server";
import { medianIdentity } from "@mediansh/agent-tools";
export const { GET } = medianIdentity(
async () => {
const user = await currentUser();
if (!user) return null;
return {
id: user.id,
email: user.primaryEmailAddress?.emailAddress,
name: user.fullName ?? undefined,
avatarUrl: user.imageUrl,
};
},
{
signIn: (returnTo) =>
`/sign-in?redirect_url=${encodeURIComponent(returnTo)}`,
},
);This is the route the widget's identity prop reads. If you have one, add
email and signIn to it.
Add the route
Deploy it with MEDIAN_KEY set on the server. See
Identity.
Paste its URL
Under Site → Sign-in, pick Your app, paste the route's full https URL, and press Use your app.
Try it
Open your site and press Sign in.
How it works
- The site sends the visitor to your route with
?median_request=<id>. - Signed in, the route signs a token and sends them to Median. Signed out,
it sends them to
signIn(returnTo), and your login brings them back to the route. - Median checks the token and returns the visitor to the page they started on, signed in.
Without the query parameter, the route answers the widget as before.
Resolver
| Field | Required | Detail |
|---|---|---|
id | Yes | Your id for the person. The widget's signed id for them must match |
email | Yes | The board counts one vote per email |
name | No | Shown in the site header and to your team |
avatarUrl | No | https only |
Options
| Option | Default | Detail |
|---|---|---|
signIn | None | (returnTo) => string. Your login page, given this route's full URL to come back to. Without it, a signed out visitor goes back to the site with Sign in to Acme first |
key | MEDIAN_KEY | The Median key to sign with |
apiUrl | MEDIAN_API_URL, then https://api.median.sh | Median's API origin |
Other servers
medianSiteRedirect builds the redirect for a server without Web Request
and Response:
import { medianSiteRedirect } from "@mediansh/agent-tools";
app.get("/median/sign-in", async (req, res) => {
const user = req.session.user;
if (!user) {
return res.redirect(`/login?next=${encodeURIComponent(req.originalUrl)}`);
}
res.set("cache-control", "no-store");
res.redirect(
await medianSiteRedirect(String(req.query.median_request), {
id: user.id,
email: user.email,
name: user.name,
}),
);
});medianSiteRedirect(request, null) sends a signed out visitor back to the
site instead.
The token
For a language without the SDK. Redirect to
https://api.median.sh/sites/sign-in?token=<jwt>, or to
https://api.median.sh/sites/sign-in?request=<median_request>&error=signed_out
when nobody is signed in.
| Part | Value |
|---|---|
| Algorithm | HS256 |
| Header | { "alg": "HS256", "typ": "JWT", "kid": "<median_pk_ id>" } |
| Claims | aud: "median-site", req, sub, email, name?, picture?, iat, exp |
req | The median_request value |
| Lifetime | exp - iat of 600 seconds or less. 300 is typical |
| Identity secret | "median_sig_" + hex(SHA-256("median:identity:" + MEDIAN_KEY)) |
| Signing key | hex(HMAC-SHA256(identity secret, "median-site-sign-in")), used as a UTF-8 string |
median_pk_ is publicKeyFromMedianKey(MEDIAN_KEY). Each token works once.
OpenID Connect
Create an app at your provider
A regular web application using the authorization code flow. Add the
Redirect URI from Site → Sign-in to its allowed callback URLs:
https://api.median.sh/sites/oidc/callback. Allow the openid,
email and profile scopes.
Add its details
Pick OpenID Connect. Enter the issuer URL, client ID and client secret, and press Use OpenID Connect.
| Provider | Issuer URL |
|---|---|
| Auth0 | https://<tenant>.<region>.auth0.com |
| Okta | https://<org>.okta.com, or a custom authorization server's URL |
| Clerk | Your Frontend API URL. Turn on the openid scope on the OAuth application |
https://accounts.google.com | |
| Microsoft Entra ID | https://login.microsoftonline.com/<tenant-id>/v2.0 |
| Fact | Detail |
|---|---|
| Discovery | Read from <issuer>/.well-known/openid-configuration when you save. Its issuer must match what you entered |
| Scopes | openid email profile |
| Flow | Authorization code with PKCE. The secret goes as client_secret_basic when the provider supports it, else client_secret_post |
| Person | sub is their id. email is required and refused when email_verified is false. name and picture are used when present |
| Linked to the widget | When your app signs the widget's id with the same sub |
| Client secret | Never shown again. The dashboard shows its last 4 characters. Leave it blank to keep it |
| New client | Enter the secret again when the issuer or client ID changes |
What signing in does
| Where | Signed in | Signed out |
|---|---|---|
| Header | Their picture or initial, with Sign out | Sign in, when Support or Feedback is on |
| Feedback | Post, vote, comment and follow. A vote, follow or comment pressed before signing in goes through after, and a post reopens with its draft. Images have to be attached again | Read only |
| Support | The chat follows them across devices, with no email prompt. With your app or OpenID Connect it is the widget's history too, and your tools know who they are | The chat belongs to this browser. With your app or OpenID Connect, the agent can put a Sign in button under a reply when one of your tools needs to know who they are |
| Help center | No change | No change |
| Fact | Detail |
|---|---|
| Session | 30 days, in a cookie on the site's address |
| Sign out | Ends the session and starts a fresh chat in that browser |
| Before signing in | Anything the browser said in the chat joins the signed in person's history |
| Addresses | Works on <slug>.median.website and a live custom domain |
| Time limit | A sign-in has 15 minutes to finish |
API and CLI
| Action | CLI | REST |
|---|---|---|
| Show | median site sign-in get | GET /v1/site/sign-in |
| Median accounts | median site sign-in set median | PATCH /v1/site/sign-in |
| Your app | median site sign-in set app --app-url <url> | PATCH /v1/site/sign-in |
| OpenID Connect | median site sign-in set oidc --issuer <url> --client-id <id> --client-secret <secret> | PATCH /v1/site/sign-in |
MCP: median.site.signIn() and setSignIn(...). The client secret is never
returned.
Troubleshooting
| The visitor sees | Cause | Fix |
|---|---|---|
| Sign in to Acme first | Your route found nobody signed in, and has no signIn | Add signIn to medianIdentity |
| No email address | The resolver or the provider sent no email | Return email. For OpenID Connect, allow the email scope |
| That didn't work | The token did not verify, the sign-in took over 15 minutes, or it was opened in another tab | Check MEDIAN_KEY on the route is a current key from this organization, then try again in one tab |
| Sign-in cancelled | They pressed Cancel, or denied access at the provider | Nothing |
| Sign-in isn't working | Your OpenID provider refused the sign-in, like a scope its app doesn't allow or a client secret it doesn't accept | Site → Sign-in shows the provider's reason, and Logs has it under Site sign-in. Fix it at the provider, then sign in again. The reason clears once a sign-in works |
Median: site sign-in needs the person's email. Return { id, email } from your resolver. | The resolver returned no email | Return email |
This sign-in link has expired or isn't valid. | An old link, or one used twice | Start again from the site |
| The dashboard says | Fix |
|---|---|
Enter your sign-in route's https URL. | Use the full URL. http only works for localhost |
We couldn't read that provider's OpenID configuration. Check the issuer URL. | Open <issuer>/.well-known/openid-configuration in a browser. It has to load |
That provider calls itself <issuer>. Use that as the issuer. | Copy the issuer from the message |
Enter the client secret from your provider. | The secret is needed the first time, and after changing the client |