# Silent sign-in

A visitor lands on your site. They already signed in to Allegro somewhere else — another site under the same parent domain, or an earlier visit — and you want to greet them as a member rather than showing them a sign-in prompt they do not need. Silent sign-in answers "is anyone signed in on this browser?" without interrupting the visitor.

The answer, when it is yes, is a short-lived authorization code that your backend redeems for the member's JWT through the normal [OAuth token endpoint](/developer/guides/oauth.md#step-3--exchange-the-code-for-a-token). Read the [OAuth 2.0 guide](/developer/guides/oauth.md) first — everything here builds on the client registration and the code exchange described there.

## Two mechanisms[​](#two-mechanisms "Direct link to Two mechanisms")

| Mechanism                           | How it runs                                         | Use it when                                                                                           |
| ----------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| [Session probe](#the-session-probe) | Cross-origin `fetch` from your page. No navigation. | Your site and the Allegro host are on different origins. **This is the usual choice.**                |
| [`prompt=none`](#promptnone)        | Top-level redirect through `/oauth/authorize`.      | You already own a full-page redirect in your flow, or your page is same-origin with the Allegro host. |

Both depend on the same prerequisite, and it is the one thing that most often makes silent sign-in look broken.

## Prerequisite: the session cookie[​](#prerequisite-the-session-cookie "Direct link to Prerequisite: the session cookie")

Allegro's browser SDK stores the member's session JWT in a cookie named `_ALLEGROT`, written client-side with `SameSite=Lax`. Silent sign-in works by reading that cookie on the Allegro host, so the browser has to be willing to send it there.

A `SameSite=Lax` cookie is only attached to a cross-origin request when the two hosts are **same-site** — that is, when they share a registrable domain. And the cookie is only visible to the Allegro host at all if it was written with a `Domain` covering both hosts.

That means an operator must set the tenant's **Cookie Domain** to the shared parent domain. With Cookie Domain set to `.acme.com`, a member who signs in on `community.acme.com` has a cookie the browser will send to `members.acme.com`, and silent sign-in works. Left blank, the cookie is scoped to the exact hostname that served the SDK, and the Allegro host never sees it.

The single most common failure

If the probe returns `unauthenticated` for a visitor you know is signed in, check Cookie Domain before anything else. There is no error and no warning — the browser simply does not send a cookie, and Allegro correctly reports that nobody is signed in on this browser.

Ask the operator to set **Organization Settings → Browser → Cookie Domain** to the parent domain shared by your site and the Allegro host. See [Browser Settings](/developer/administration/browser-settings.md).

Silent sign-in therefore cannot work at all when Allegro is served from a domain unrelated to your site — `app.example.com` calling `members.acme.com` will get `unauthenticated` forever, no matter who is signed in. Those visitors need the [interactive flow](/developer/guides/oauth.md).

## The session probe[​](#the-session-probe "Direct link to The session probe")

```text
GET https://<your-tenant-domain>/oauth/session?client_id=<your-client-id>

```

The probe is **read-only in every path**. It creates no member, mints no session, and modifies nothing — a visitor who loads your page and leaves has changed nothing in Allegro.

### The request[​](#the-request "Direct link to The request")

Send it as a credentialed cross-origin `fetch`. The browser supplies `Origin` itself; you cannot set it.

```js
const response = await fetch(
    `https://<your-tenant-domain>/oauth/session?client_id=${CLIENT_ID}`,
    { credentials: 'include' },
);

const session = await response.json();

if (session.status === 'authenticated') {
    // Hand session.code to your own backend to redeem.
    await fetch('/allegro/adopt-session', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ code: session.code }),
    });
}

```

`credentials: 'include'` is required. Without it the browser sends no cookie and you will always be told nobody is signed in.

Call the tenant's **canonical domain**. Every other hostname answers a `GET` with a `302`, and a redirect carries no CORS headers, so the fetch fails as a CORS error rather than following it. See [Canonical host and issuer](/developer/guides/oauth.md#canonical-host-and-issuer).

### The responses[​](#the-responses "Direct link to The responses")

Somebody is signed in:

```http
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Access-Control-Allow-Origin: https://community.acme.com
Access-Control-Allow-Credentials: true
Vary: Origin

{
    "status": "authenticated",
    "code": "aB3…64-characters…9zQ",
    "expires_in": 30
}

```

Nobody is signed in:

```http
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Access-Control-Allow-Origin: https://community.acme.com
Access-Control-Allow-Credentials: true
Vary: Origin

{ "status": "unauthenticated" }

```

Both are `200`. Nobody being signed in is a normal answer, not an error, so do not treat a non-`authenticated` status as a failure to retry. `Cache-Control: no-store` is set on both, because the first response carries a credential and the second would otherwise be cached as a permanent "no".

| Field        | Present when    | Description                                             |
| ------------ | --------------- | ------------------------------------------------------- |
| `status`     | Always          | `authenticated` or `unauthenticated`.                   |
| `code`       | `authenticated` | A single-use authorization code. Redeem it immediately. |
| `expires_in` | `authenticated` | Seconds the code stays redeemable. Always `30`.         |

### Why a code and not a token[​](#why-a-code-and-not-a-token "Direct link to Why a code and not a token")

Anything your page can read, a script on your page can steal. A member JWT is a bearer credential: whoever holds it can act as the member until it expires. A 30-second single-use authorization code cannot be spent by whoever holds it, because redeeming it requires the client secret — which only your backend has. So the probe hands the browser the weakest possible thing that still gets your backend a token.

### Registered origins[​](#registered-origins "Direct link to Registered origins")

The probe only answers a request whose `Origin` header is on the calling client's **allowed origins** list. Origins are matched byte for byte: no wildcards, no suffix matching, no trailing slash. Every site that probes needs its own entry, and a parent domain does not cover its subdomains.

A refusal is a `403` **with no CORS headers at all**:

```http
HTTP/1.1 403 Forbidden
Content-Type: application/json

{ "error": "invalid_request" }

```

Because the CORS headers are absent, the browser surfaces this to your JavaScript as a generic CORS failure and your page cannot tell what was wrong. That is deliberate — a distinguishable response would let anyone enumerate which clients and origins exist. The same `403` covers all of it:

* No `Origin` header on the request
* No `client_id`, or a `client_id` that does not exist
* A client that is disabled
* A client with no registered origins
* An origin that is not registered against **this** client
* An origin that is a suffix of a registered one

When you hit this, the fix is on the Allegro side: ask the operator to add your exact origin to the client's allowed origins.

### Preflight[​](#preflight "Direct link to Preflight")

A plain credentialed `GET` with no custom headers is a simple request and is not preflighted, so most integrations never see an `OPTIONS`. If yours does add a header that triggers one, the endpoint answers it:

```http
OPTIONS /oauth/session?client_id=<your-client-id> HTTP/1.1
Host: <your-tenant-domain>
Origin: https://community.acme.com
Access-Control-Request-Method: GET

```

```http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://community.acme.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 600
Vary: Origin

```

`Access-Control-Allow-Origin` always echoes the exact requesting origin rather than `*`, which credentialed requests forbid. An unregistered origin gets the same headerless `403` as above.

### Redeeming the code[​](#redeeming-the-code "Direct link to Redeeming the code")

Hand the code to your own backend and exchange it exactly as you would a code from the interactive flow — same endpoint, same `grant_type`, same client credentials:

```bash
curl https://<your-tenant-domain>/oauth/token \
  -d grant_type=authorization_code \
  -d code="<probe-code>" \
  -d redirect_uri="<your-first-registered-redirect-uri>" \
  -d client_id="<your-client-id>" \
  -d client_secret="<your-client-secret>"

```

Two differences from a redirect-issued code:

* **`redirect_uri` is your registered value, not one you chose.** A probe code never traveled through a redirect, so there is no URI to echo back. It is stamped with the client's **first registered redirect URI**, and the token endpoint still compares them exactly, so send that value.
* **No `code_verifier`.** A probe code carries no PKCE challenge, so none is required — including for a client that requires PKCE on the interactive flow.

You receive the same response as the interactive flow:

```json
{
    "access_token": "<member-jwt>",
    "token_type": "Bearer",
    "expires_in": 31536000
}

```

The token rides the visitor's **existing** device session rather than a new one, so a probe on every page load does not multiply that visitor's sessions, and a sign-out on the Allegro side invalidates the token you were issued.

Codes expire in 30 seconds and are single-use. Redeem immediately; a second attempt with the same code returns `invalid_grant`.

### Rate limits[​](#rate-limits "Direct link to Rate limits")

The probe is expected to run once per visitor per page load, so the per-browser limit is generous while a second ceiling still bounds any single host:

| Limit                   | Scope                   |
| ----------------------- | ----------------------- |
| 120 requests per minute | Per browser, per client |
| 600 requests per minute | Per IP address          |

Exceeding either returns `429`. Probe once per page load and cache the answer for the life of the page; do not poll.

## `prompt=none`[​](#promptnone "Direct link to promptnone")

The authorize endpoint also accepts `prompt=none`, which asks it to answer from the existing session or not at all — it renders no page in either outcome.

```text
GET https://<your-tenant-domain>/oauth/authorize
    ?client_id=<your-client-id>
    &redirect_uri=https://your-app.example.com/callback
    &response_type=code
    &prompt=none
    &state=<state>

```

Signed in, you get the same callback redirect as the interactive flow:

```text
https://your-app.example.com/callback?code=<authorization-code>&state=<state>

```

Not signed in, you get an error back at the same place:

```text
https://your-app.example.com/callback?error=login_required&state=<state>

```

`none` is the only supported `prompt` value, and OpenID Connect forbids combining it with any other, so `prompt=none login` is rejected with `error=invalid_request`. A `prompt=none` request against a client that requires PKCE and without a `code_challenge` also comes back as `error=invalid_request` rather than an error page, since there is no person to read one.

Codes issued this way get the full 10-minute lifetime, ride the visitor's existing device session, and redeem with the `redirect_uri` you sent — an ordinary code exchange in every respect.

note

`prompt=none` needs a top-level navigation, not a frame

Two separate things rule out the hidden-iframe pattern, and it is worth knowing both, because fixing one does not fix the other.

The `SameSite=Lax` cookie described above rides along on a top-level navigation, so a full-page redirect works. It is **not** sent on a cross-site subresource load, so `prompt=none` in an iframe on a page that is not same-site with the Allegro host gets you a permanent `login_required`.

Being same-site is not enough either. Allegro is served behind an `X-Frame-Options` response header, so the browser refuses to render the frame at all for any origin but Allegro's own — and a sibling host under the same parent domain is still a different origin. The cookie would be sent; the frame is blocked before that matters.

Use a top-level redirect, or use the session probe. The probe needs no navigation and no frame, and `X-Frame-Options` does not apply to it: it governs framing, not `fetch`.

## Troubleshooting[​](#troubleshooting "Direct link to Troubleshooting")

| Symptom                                                            | Cause                                                                                                        |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `{"status":"unauthenticated"}` for a visitor you know is signed in | Cookie Domain is not set to the shared parent domain, or `credentials: 'include'` is missing.                |
| A CORS error in the console, `403` in the network tab              | Your origin is not registered against this client, or `client_id` is wrong or disabled.                      |
| A CORS error with a `302` in the network tab                       | You are calling a non-canonical hostname. Use the tenant's canonical domain.                                 |
| `invalid_grant` when redeeming a probe code                        | The code expired (30 seconds), was already used, or `redirect_uri` is not the client's first registered URI. |
| `error=login_required` every time on `prompt=none`                 | A hidden iframe. Use a top-level redirect or the session probe.                                              |
| `429`                                                              | Polling. Probe once per page load.                                                                           |

## Related[​](#related "Direct link to Related")

* [OAuth 2.0](/developer/guides/oauth.md) — Client registration, the interactive flow, and the token endpoint
* [Browser Settings](/developer/administration/browser-settings.md) — Cookie Domain and CORS allowed origins
* [JWT Verification](/developer/guides/jwt.md) — Verifying the token you receive
