Who can reach your Service
Every Service is public. Decide who may use it, and check the signed-in user's token when it's the backend for a Shell app.
Every Service is on the public internet: anyone who knows its address can send it a request. Groundfloor delivers those requests to your container, but it doesn't sign anyone in or check who they are. Your Service decides who gets in.
Choose what your Service needs
| Your Service is… | Example | What you do |
|---|---|---|
| Open to everyone | A website, blog, docs, public API or webhook receiver | Nothing. That's the default |
| An app with its own logins | WordPress, Ghost, Metabase, or your own app with user accounts | Nothing from Groundfloor: the app's own login protects it. Finish its installer as soon as it's deployed, and keep admin passwords strong |
| The backend for a Shell app | An API your Shell app calls with __GF_GET_ACCESS_TOKEN__() | Check the user's token on every request, as below |
Signing in to your Shell app doesn't protect your Service
Users sign in to your Shell site, not to the Services behind it. Anyone can call a Service's address directly, without signing in. If your Service is the backend for a Shell app, it must check the token on every request itself.
Check the token in a Shell app's backend
Your Shell app sends the signed-in user's token as Authorization: Bearer <token> (see Secrets and backends). The token is a JWT signed by your workspace's sign-in. Your backend checks it, then decides what the user may do.
Find your issuer and client ID
Ask Groundfloor for your Shell site's sign-in settings. No token is needed:
curl "https://platform.groundfloor.cloud/v1/public/workspaces/by-host?host=<workspace-slug>.app.groundfloor.cloud"Use your own domain as host if your Shell site has one. The answer looks like this:
{
"workspace_id": "3f6c…",
"mode": "groundfloor",
"oidc": {
"issuer": "https://auth.groundfloor.cloud/realms/gf-ws-3f6c…",
"client_id": "shell"
},
"site_host": "<workspace-slug>.app.groundfloor.cloud"
}- Check that
workspace_idis your workspace's ID, so you know you asked about the right site. oidc.issueris your issuer. With Groundfloor sign-in it's unique to your workspace; with External sign-in it's your identity provider's issuer.oidc.client_idis your client ID:shellwith Groundfloor sign-in, or the client ID you entered for External sign-in.oidcisnullwhen the sign-in mode is None: there are no tokens to check.
Look these up once, when you set up the Service. Copy both values exactly as they appear, including any trailing / on the issuer, and give them to your Service as AUTH_ISSUER and AUTH_CLIENT_ID, for example as ENV lines in its Dockerfile. Neither is secret. Look them up again if you change your sign-in mode.
Never look them up at runtime from the request's Host header: whoever sends the request chooses that header, so they could point your Service at a site whose tokens they control.
What to check
Reject the request with 401 unless all of these hold:
- Signature: the token is signed with
RS256by one of the keys at{issuer}/protocol/openid-connect/certs. That address holds more than one key, so pick the one whosekidmatches the token's header. With External sign-in, use your provider's key address instead (itsjwks_uri). - Issuer:
issequals your issuer exactly. - Expiry:
expis in the future. - Client: the token was issued to your client ID.
- With Groundfloor sign-in,
azpequals your client ID. Don't requireaudto name your app: tokens don't carry an audience for your app yet. - With External sign-in, the token must name your client ID, either in
azp(or your provider's equivalent, such ascidorappid) or inaud. Never accept a token that names it in neither.
- With Groundfloor sign-in,
Then return 403 if the user is signed in but their role doesn't allow what they asked for.
With External sign-in, check whether your provider lets anyone sign up. If it does, "any signed-in user" means anyone at all, so check a role for everything that isn't public.
Read the user and their role
| Claim | What it is |
|---|---|
realm_access.roles | The user's roles: the built-in viewer, user and admin, and roles you created on App authentication → Roles. The list also holds roles your app can ignore, such as offline_access, uma_authorization and names starting with default-roles- |
organization | The user's companies (tenants), when Multi-tenant is on in App authentication |
With External sign-in, roles come from your identity provider, in whatever claim it uses.
To tie records to a user, use the user ID described in Know who the user is.
With Multi-tenant on, roles don't separate companies
A role applies across every company: an admin in one company is an admin for your whole app. So filter every read and write by the user's organization claim as well as their role. The claim can hold more than one company, so check that the record's company is one of them. Check its exact form in a token from your own workspace; the examples below accept a list of company names or an object keyed by company name.
Example: FastAPI
Uses PyJWT (pip install "pyjwt[crypto]" fastapi). PyJWKClient caches the keys and picks the one named by the token's kid. When a token names a kid it doesn't have, it fetches the keys again, every time; the timeout stops a slow fetch from holding up requests.
import os
import jwt
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
ISSUER = os.environ["AUTH_ISSUER"] # exactly as by-host returned it
CLIENT_ID = os.environ["AUTH_CLIENT_ID"] # "shell" with Groundfloor sign-in
JWKS_URL = os.environ.get(
"AUTH_JWKS_URL", ISSUER.rstrip("/") + "/protocol/openid-connect/certs"
)
jwks = jwt.PyJWKClient(JWKS_URL, timeout=5)
bearer = HTTPBearer(auto_error=False)
app = FastAPI()
def issued_to_us(claims: dict) -> bool:
if CLIENT_ID in (claims.get("azp"), claims.get("cid"), claims.get("appid")):
return True
aud = claims.get("aud") # External sign-in only; Groundfloor tokens use azp
return aud == CLIENT_ID or (isinstance(aud, list) and CLIENT_ID in aud)
def current_user(creds: HTTPAuthorizationCredentials | None = Depends(bearer)) -> dict:
unauthorized = HTTPException(401, "Sign in first", headers={"WWW-Authenticate": "Bearer"})
if creds is None:
raise unauthorized
try:
key = jwks.get_signing_key_from_jwt(creds.credentials)
claims = jwt.decode(
creds.credentials,
key.key,
algorithms=["RS256"],
issuer=ISSUER,
options={"require": ["exp", "iss"], "verify_aud": False},
leeway=30,
)
except jwt.PyJWKClientConnectionError:
raise HTTPException(503, "Can't reach the sign-in service")
except jwt.PyJWTError:
raise unauthorized
if not issued_to_us(claims):
raise unauthorized
return claims
def user_roles(claims: dict) -> set[str]:
access = claims.get("realm_access")
roles = access.get("roles") if isinstance(access, dict) else None
return {r for r in roles if isinstance(r, str)} if isinstance(roles, list) else set()
def user_companies(claims: dict) -> set[str]:
org = claims.get("organization")
if isinstance(org, dict):
return {k for k in org if isinstance(k, str)}
if isinstance(org, list):
return {o for o in org if isinstance(o, str)}
return set()
def require_role(*roles: str):
def check(claims: dict = Depends(current_user)) -> dict:
if user_roles(claims).isdisjoint(roles):
raise HTTPException(403, "You don't have access to this")
return claims
return check
@app.get("/api/orders")
def list_orders(claims: dict = Depends(require_role("user", "admin"))):
companies = user_companies(claims) # Multi-tenant: only this user's companies
return {"orders": [], "companies": sorted(companies)}
@app.post("/api/refunds")
def create_refund(claims: dict = Depends(require_role("admin"))):
return {"ok": True} # admins onlyverify_aud is off because Groundfloor tokens can carry an aud that isn't yours, and PyJWT rejects any aud it wasn't told to expect; issued_to_us checks the client instead. Set AUTH_JWKS_URL only with External sign-in. A malformed realm_access or organization claim counts as no roles and no companies, so the request gets 403, not an error.
Example: Node.js
Uses jose (npm install jose), as Express middleware. createRemoteJWKSet caches the keys and picks the one named by the token's kid. It fetches the keys again for an unknown kid at most every 30 seconds, and gives up on a fetch after 5 seconds.
import { createRemoteJWKSet, jwtVerify } from "jose";
const ISSUER = process.env.AUTH_ISSUER; // exactly as by-host returned it
const CLIENT_ID = process.env.AUTH_CLIENT_ID; // "shell" with Groundfloor sign-in
if (!ISSUER || !CLIENT_ID) throw new Error("Set AUTH_ISSUER and AUTH_CLIENT_ID");
const JWKS = createRemoteJWKSet(
new URL(process.env.AUTH_JWKS_URL ?? `${ISSUER.replace(/\/$/, "")}/protocol/openid-connect/certs`),
);
function issuedToUs(p) {
if ([p.azp, p.cid, p.appid].includes(CLIENT_ID)) return true;
return Array.isArray(p.aud) ? p.aud.includes(CLIENT_ID) : p.aud === CLIENT_ID; // External only
}
// req.user holds the token's claims, or the request ends with 401.
export async function requireUser(req, res, next) {
const [scheme, token] = (req.headers.authorization ?? "").split(" ");
if (scheme?.toLowerCase() !== "bearer" || !token) return res.status(401).end();
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
algorithms: ["RS256"],
requiredClaims: ["exp"],
clockTolerance: 30,
});
if (!issuedToUs(payload)) return res.status(401).end();
req.user = payload;
next();
} catch {
res.status(401).end();
}
}
export function requireRole(...roles) {
return (req, res, next) => {
const r = req.user?.realm_access?.roles;
const userRoles = Array.isArray(r) ? r : [];
if (!roles.some((role) => userRoles.includes(role))) return res.status(403).end();
next();
};
}
// Multi-tenant: the companies whose records this user may see.
export function userCompanies(user) {
const org = user?.organization;
if (Array.isArray(org)) return org.filter((o) => typeof o === "string");
if (org && typeof org === "object") return Object.keys(org);
return [];
}app.get("/api/orders", requireUser, requireRole("user", "admin"), listOrders);
app.post("/api/refunds", requireUser, requireRole("admin"), createRefund);Allow calls from your Shell site
Your Shell site and your Service are on different addresses, so the browser asks the Service first (a CORS preflight) before sending a request with an Authorization header. Unless the Service answers it, the browser blocks the call. Allow your Shell site's address, and your own domain if it has one:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://<workspace-slug>.app.groundfloor.cloud"],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)In Node.js, use the cors package with the same settings. Don't use a bare cors(), which allows every origin:
import cors from "cors";
app.use(cors({
origin: ["https://<workspace-slug>.app.groundfloor.cloud"],
allowedHeaders: ["Authorization", "Content-Type"],
}));The middleware answers the preflight before your token check runs, so preflights don't get 401. The token travels in a header, not a cookie, so you don't need to allow credentials. Never combine * as the allowed origin with credentials.
Every app on your Shell site can use the user's token
Only install apps you trust
Every app on your Shell site, including apps you didn't write, can get the signed-in user's token and call your backends as that user. A token a user gets in one of your apps is also accepted by your other apps' backends in the workspace. Only install apps you trust, and check roles for anything sensitive.
Tokens issued for one app only, sign-in settings passed to your Service automatically, and a helper that checks tokens for you are coming soon.
Next steps
- Secrets and backends — call your backend from a Shell app.
- Sign-in and users — what's in the user's token.
- App authentication — sign-in modes, users and roles.