Skip to content
View as Markdown

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 Authorization header, 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 tenantKey from the workspace your signed-in user selected, checked against your own authorization rules.
  • Derive externalSubjectId from 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.