Authentication
Your backend authenticates to SocialScope with an application key sent as a bearer token on every GraphQL request. The key identifies your application, not a user or a tenant. SocialScope is server-to-server only: the endpoint does not enable CORS, so a browser on your origin cannot call it, and the key must never reach browser code anyway. This page covers the key's format, where to store it, how to call the endpoint, rotation and what each authentication failure means.
The endpoint
| Environment | GraphQL URL |
|---|---|
| Dev | https://connect.socialscope.hardscope.incdev.dev/graphql |
| Production | To be announced |
The endpoint accepts only POST /graphql with a JSON body. A GET returns 404. The other paths on that host serve the browser side of Connect and provider callbacks, and your server never calls them.
The key
An admin issues the key when your app is registered and prints it once. It looks like this:
<key-id>.<secret>
The key ID is a UUID and the secret is 64 hexadecimal characters. Send it as:
Authorization: Bearer <key-id>.<secret>
Anything that does not match Bearer <uuid>.<64 hex> exactly is rejected with UNAUTHENTICATED.
Store it on the server only
SOCIALSCOPE_GRAPHQL_URL=https://connect.socialscope.hardscope.incdev.dev/graphql
SOCIALSCOPE_CONSUMER_KEY=<key-id>.<secret>
Rules:
- Keep it in your server's secret store or environment, next to the GraphQL URL.
- Never put it in
NEXT_PUBLIC_*,VITE_*or any variable that is bundled into browser code, in a mobile app, in browser storage, in source control or in client logs. - Never log the
Authorizationheader, request bodies that contain launch tokens or result proofs, or raw response bodies. - Use a separate key for each environment. Use only the key issued to your app.
The same rule covers the other secrets in the flows: the Connect launchToken goes only into the auto-submitted form on your page, and the Google identity resultProof never leaves your server.
Call it
// lib/socialscope.ts
// Server only. Never import this file from browser code.
const TIMEOUT_MS = 10_000; // 10 s
export class SocialScopeError extends Error {
constructor(
readonly code: string,
readonly correlationId?: string,
readonly retryAfterSeconds?: number,
) {
super(code);
this.name = "SocialScopeError";
}
}
type GraphQLResponse<T> = {
data?: T;
errors?: {
message: string;
extensions?: { code?: string; correlationId?: string; retryAfterSeconds?: number };
}[];
};
export async function socialscope<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
const url = process.env.SOCIALSCOPE_GRAPHQL_URL;
const key = process.env.SOCIALSCOPE_CONSUMER_KEY;
if (!url || !key) throw new SocialScopeError("SOCIALSCOPE_NOT_CONFIGURED");
let response: Response;
try {
response = await fetch(url, {
method: "POST",
cache: "no-store", // never let a framework cache a SocialScope response
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify({ query, variables }),
});
} catch {
// Network failure or timeout. Never log the request: it carries the key.
throw new SocialScopeError("SOCIALSCOPE_UNAVAILABLE");
}
// A body over 64 KiB is refused with HTTP 413 before GraphQL runs, so there is no GraphQL error body.
if (response.status === 413) throw new SocialScopeError("SOCIALSCOPE_REQUEST_TOO_LARGE");
// Errors arrive with HTTP 200, or 400 for validation, limit and batching errors. Read the body either way.
const payload = (await response.json().catch(() => null)) as GraphQLResponse<T> | null;
const first = payload?.errors?.[0];
if (first) {
throw new SocialScopeError(first.extensions?.code ?? "UPSTREAM_UNAVAILABLE", first.extensions?.correlationId, first.extensions?.retryAfterSeconds);
}
if (!response.ok || !payload?.data) throw new SocialScopeError("SOCIALSCOPE_UNAVAILABLE");
return payload.data;
}
export type Scope = { tenantKey: string; externalSubjectId: string };
In Next.js, call this only from route handlers, server actions or server components. The cache: "no-store" option stops the framework from serving a cached SocialScope response. SOCIALSCOPE_NOT_CONFIGURED, SOCIALSCOPE_REQUEST_TOO_LARGE and SOCIALSCOPE_UNAVAILABLE are local codes this helper invents. Every other code comes from SocialScope.
Do not rely on the HTTP status alone. A failed operation usually arrives with HTTP 200 and an errors array. Invalid queries, request-limit errors and batched requests arrive with HTTP 400 and the same errors shape. A body over 64 KiB gets HTTP 413 before GraphQL runs, with no GraphQL body. Always read the body when there is one.
Smoke test with curl
status is the only operation that needs no key. Use it to confirm the route works:
curl -sS -X POST https://connect.socialscope.hardscope.incdev.dev/graphql \
-H 'content-type: application/json' \
-d '{"query":"query Status { status { ok message } }"}'
Then check the key with integrationReadiness. Read the key from your environment rather than typing it, so it does not land in shell history:
curl -sS -X POST "$SOCIALSCOPE_GRAPHQL_URL" \
-H 'content-type: application/json' \
-H "authorization: Bearer $SOCIALSCOPE_CONSUMER_KEY" \
-d '{"query":"query Readiness { integrationReadiness { providers { provider available } googleIdentityAvailable } }"}'
What the key does and does not prove
The key proves which application is calling. It does not prove which of your users is calling. Every connection operation takes a scope of { tenantKey, externalSubjectId }, and SocialScope trusts your backend to send the right one.
- Derive
tenantKeyfrom the workspace your signed-in user selected, checked against your own authorization rules. - Derive
externalSubjectIdfrom your signed-in user's stable ID. - Never read either value from a browser form, URL or header that the user controls. Doing so would let one user connect or read accounts in another user's name.
tenantKey must be one of your registered tenant keys. An unregistered tenant gets FORBIDDEN or NOT_FOUND, depending on the operation.
Rotation and expiry
- A key can have an expiry chosen by the admin, or no expiry.
- Your application can have at most two active keys at once, so you can rotate without downtime: ask for a new key, deploy it, confirm traffic works, then ask the admin to revoke the old one.
- A key with no expiry stays valid until revoked. Rotate it on your own schedule.
- If a key may have leaked, ask the admin to revoke it at once and issue a replacement.
Authentication failures
| Code | Meaning | What to do |
|---|---|---|
UNAUTHENTICATED |
The key is missing, malformed, unknown, revoked or expired | Check the configured value and expiry. Ask the admin for a new key |
FORBIDDEN |
The key is valid but your application is disabled, or this call is outside your app's policy | Ask the admin to check your client |
Each error carries extensions.correlationId, a UUID. Log it, without the key, and quote it when you ask the SocialScope team for help.
Request limits
Every request is checked against these limits before it runs:
| Limit | Value |
|---|---|
| Operations per request | 1. Batched requests are rejected |
| Aliases | 8 |
| Field depth | 8 |
| Cost | 100, where each root field costs 10 and each nested field 1 |
| JSON body | 64 KiB |
A request over the alias, depth, cost or operation limit fails with HTTP 400 and BAD_USER_INPUT, with a message such as GraphQL cost limit exceeded. A body over 64 KiB fails with HTTP 413 before GraphQL runs. In practice this means one or two root fields per request. Send separate requests rather than combining many operations with aliases.