Sign in with Google
xhost runs the Google sign-in dance and hands your app a signed identity token. Your app verifies it and decides access.
How it works
xhost is an identity provider, not a gatekeeper. It runs the Google login dance
and, on success, sets a cookie named __Host-xhost_id on your
channel's hostname. That cookie is a signed JWT carrying the visitor's identity.
Nothing is blocked at the edge — every request reaches your app. Your app
reads the cookie, verifies its signature against xhost's public keys, and decides
for itself whether to allow the request.
This gives you full control: layer your own password/SMS login, allowlists,
roles, or paid tiers on top — or ignore /xhost-auth/* entirely
and run your own auth. xhost gates nothing.
/xhost-auth/* (login, logout, whoami)
works on every deployed channel with no activation step. There is no
protected-paths list to set — gating lives entirely in your app code.
The identity cookie
After Google sign-in, xhost sets __Host-xhost_id — an
HttpOnly, Secure, host-only cookie (the
__Host- prefix forces Path=/ and no Domain,
so the cookie is never sent to another host). Its value is an RS256-signed JWT
with these claims:
{
"iss": "https://auth.xhostd.com",
"aud": "your-app.xhostd.com", // your channel's exact hostname
"sub": "1078...", // stable Google user id
"email": "visitor@gmail.com",
"name": "Visitor Name",
"iat": 1750000000,
"exp": 1750086400
}
__Host-xhost_id is a reserved cookie name. Do not
set it or read it as a raw value from your app — always verify it (below).
Verify the cookie in your app
On each request that needs a user, verify the cookie against xhost's published public keys. The rules are the same in every language:
- JWKS URL:
https://auth.xhostd.com/xhost-auth/jwks(fetch and cache it). - Pin
RS256. Readkidfrom the token header to select the key — never let the token's ownalgchoose (prevents alg-confusion). - Require
iss == "https://auth.xhostd.com",aud == "<your-host>"(your exact hostname — different for prod vs. staging), andexpnot in the past. - On success, use
subas the primary key for your own user records;emailcan change.
jwt-decode,
jwt.decode(..., verify=False)) — they do not check the
signature and will accept forged tokens.
Python — PyJWT
import jwt
from jwt import PyJWKClient
ISSUER = "https://auth.xhostd.com"
AUDIENCE = "your-app.xhostd.com" # your exact channel hostname
_jwks = PyJWKClient("https://auth.xhostd.com/xhost-auth/jwks")
def current_user(request):
token = request.cookies.get("__Host-xhost_id")
if not token:
return None
try:
key = _jwks.get_signing_key_from_jwt(token).key
claims = jwt.decode(
token, key,
algorithms=["RS256"], # pin RS256
issuer=ISSUER,
audience=AUDIENCE,
)
except jwt.InvalidTokenError:
return None
return {"sub": claims["sub"], "email": claims["email"], "name": claims.get("name")}
Node — jsonwebtoken + jwks-rsa
const jwt = require("jsonwebtoken");
const jwksClient = require("jwks-rsa");
const ISSUER = "https://auth.xhostd.com";
const AUDIENCE = "your-app.xhostd.com"; // your exact channel hostname
const jwks = jwksClient({
jwksUri: "https://auth.xhostd.com/xhost-auth/jwks",
cache: true, cacheMaxAge: 10 * 60 * 1000, rateLimit: true,
});
const getKey = (header, cb) =>
jwks.getSigningKey(header.kid, (e, k) => cb(e, e ? null : k.getPublicKey()));
function currentUser(req) {
return new Promise((resolve) => {
const m = (req.headers.cookie || "").match(/(?:^|;\s*)__Host-xhost_id=([^;]+)/);
if (!m) return resolve(null);
jwt.verify(
decodeURIComponent(m[1]), getKey,
{ algorithms: ["RS256"], issuer: ISSUER, audience: AUDIENCE }, // pin RS256
(err, claims) =>
resolve(err ? null : { sub: claims.sub, email: claims.email, name: claims.name }),
);
});
}
Go — go-oidc
import (
"context"
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
)
const issuer = "https://auth.xhostd.com"
const audience = "your-app.xhostd.com" // your exact channel hostname
// keySet caches xhost's JWKS; build once at startup.
var keySet = oidc.NewRemoteKeySet(context.Background(),
"https://auth.xhostd.com/xhost-auth/jwks")
var verifier = oidc.NewVerifier(issuer, keySet, &oidc.Config{
ClientID: audience, // checks aud
SupportedSigningAlgs: []string{"RS256"}, // pin RS256
})
func currentUser(r *http.Request) (map[string]any, bool) {
c, err := r.Cookie("__Host-xhost_id")
if err != nil {
return nil, false
}
tok, err := verifier.Verify(r.Context(), c.Value)
if err != nil {
return nil, false
}
var claims map[string]any
if err := tok.Claims(&claims); err != nil {
return nil, false
}
return claims, true
}
Gate your routes
With a verifier in hand, gate whatever needs a user. If verification fails,
redirect browsers to login or return 401/403 for API
calls. Restricting which signed-in users may proceed (allowlists, paid
tiers, team membership) is your app's job — check claims.email
or claims.sub.
from flask import request, redirect, Response
@app.get("/admin")
def admin():
user = current_user(request)
if not user:
return redirect(f"/xhost-auth/login?return_to={request.path}")
if not user["email"].endswith("@acme.com"):
return Response("Access denied", status=403)
return render_admin(user)
Persist the user in your DB
sub (Google's stable identifier — emails can change).
user = current_user(request) # verified claims, see above
db.session.execute(
insert(User)
.values(sub=user["sub"], email=user["email"], name=user["name"])
.on_conflict_do_update(
index_elements=["sub"],
set_={"email": user["email"], "name": user["name"],
"last_seen_at": func.now()},
)
)
Client-side / SPA apps
The cookie is HttpOnly, so browser JS cannot read it. Call
GET /xhost-auth/whoami — the cookie is sent automatically
same-origin and the gateway returns the verified identity:
const me = await fetch("/xhost-auth/whoami").then(r => r.json());
// { logged_in: true, email, name, sub }
// or { logged_in: false, login_url }
if (me.logged_in) { document.body.dataset.email = me.email; }
else { window.location = me.login_url; }
Login / logout links
Send signed-out users to /xhost-auth/login?return_to=<path>.
After Google → finalize → cookie set → redirect to
return_to. For sign-out:
/xhost-auth/logout?return_to=/.
<a href="/xhost-auth/login?return_to=/admin">Sign in</a>
<a href="/xhost-auth/logout?return_to=/">Sign out</a>
Security model
- The cookie JWT is signed with xhost's private key; your app verifies it with the public key from the JWKS endpoint. Apps can verify but cannot forge an xhost token — signature forgery stays impossible even if your app container is compromised.
-
The
__Host-prefix forces the cookie host-only (Secure,Path=/, noDomain), so a token minted for one channel host is never sent to another. Theaudclaim (= hostname) adds defense-in-depth against a misrouted token. -
xhost unconditionally strips inbound
X-Xhost-*request headers at the edge, so a client can never spoof identity headers. Read identity from the verified cookie, not headers. - Google ID tokens are verified cryptographically (RS256, Google's JWKS) during the dance, before the cookie is minted.
-
Multiple login sources? Verify each issuer against its own
keys:
iss: https://auth.xhostd.comvia the JWKS above, your own issuer via your own key. Same claim shape, no trust shared.
Known limitations
- xhost's built-in dance is Google only. For GitHub / Apple / magic links / SMS, run your own login — the cookie model lets you mix yours with xhost's.
-
Anyone with a Google account can complete the dance — no built-in
allowlist. Filter in your app code against
claims.email. - xhost cannot tell you who has accounts on your app — keep your own DB.
What this isn't
- It's not a Google Workspace integration — you can't request Drive/Gmail/Calendar scopes.
- It's not SSO across xhost channels — the cookie is host-only, so every channel is its own login surface.