Skip to content
View as Markdown

Sign people in with Google

SocialScope can run "Continue with Google" for your app, so you can sign people up or in without operating your own Google OAuth client. Your backend starts an identity attempt, your page auto-posts a one-use launch token to SocialScope, the person approves at Google, and your backend redeems the verified result exactly once. You then create or find your own user and start your own session. This flow is separate from connecting a YouTube channel: it creates no SocialScope user, no connection and no YouTube access.

How it differs from Connect

Google identity Social Connect
Needs an existing app user No. Only a tenant key and a return URL Yes, a scope with externalSubjectId
Google permissions openid email profile Provider read permissions
Result Verified claims, returned once to your backend A durable connection
Return hint ss_identity_id ss_session_id
Launch path /api/identity/redeem /api/connect/redeem

A person can run both flows at the same time in different tabs.

Before you start

  • An admin enables Google identity for your client and registers your identity return URL, for example https://app.example.com/auth/google/return. The URL must be on one of your registered origins and must not contain any query parameter starting with ss_.
  • integrationReadiness.googleIdentityAvailable is true. If it is false or the readiness call fails, hide or disable the Google button.
  • Your server has SOCIALSCOPE_GRAPHQL_URL and SOCIALSCOPE_CONSUMER_KEY. See Authentication.
  • Pull request preview deployments cannot use Google identity. SocialScope serves per-PR preview hosts through one shared preview client registered with a host template instead of exact origins, and that client is limited to Connect. See Testing.

The examples import socialscope from lib/socialscope.ts. Copy that file from Authentication. It keeps extensions.code and retryAfterSeconds on every error, which you need for RATE_LIMITED.

The flow

sequenceDiagram
  autonumber
  actor B as Browser
  participant App as Your backend
  participant SS as SocialScope
  participant G as Google
  B->>App: Continue with Google
  App->>SS: createGoogleIdentitySession(tenantKey, returnUrl)
  SS-->>App: id, launchUrl, launchToken, resultProof
  App->>App: Save id, tenantKey, resultProof with the browser session
  App-->>B: no-store page that auto-posts launchToken
  B->>SS: POST /api/identity/redeem (Origin = your origin)
  SS-->>B: SocialScope page names your app, then Google
  B->>G: Sign in and approve
  G-->>B: Redirect to SocialScope callback
  B->>SS: Callback (SocialScope verifies the ID token)
  SS-->>B: 303 to returnUrl?ss_identity_id={id}
  B->>App: GET returnUrl?ss_identity_id={id}
  App->>SS: redeemGoogleIdentityResult(id, tenantKey, resultProof)
  SS-->>App: status, issuer, googleSub, email, emailVerified, name, pictureUrl
  App->>App: Find or create user by issuer + googleSub, then start your session

1. Start the attempt

There may be no user yet, so bind the attempt to the browser session instead, for example a short-lived, signed, HttpOnly pre-login cookie. Your server picks the tenant and return URL from its own configuration.

import { socialscope } from "./lib/socialscope";
import { launchPage } from "./lib/socialscope-launch";

const START_GOOGLE_IDENTITY = `
  mutation StartGoogleIdentity($input: GoogleIdentityInput!) {
    createGoogleIdentitySession(input: $input) {
      id
      launchUrl
      launchToken
      resultProof
      expiresAt
      resultExpiresAt
    }
  }
`;

type IdentitySession = {
  id: string;
  launchUrl: string;
  launchToken: string;
  resultProof: string;
  expiresAt: string; // authorization deadline, 10 minutes after creation
  resultExpiresAt: string; // redeem deadline, 20 minutes after creation
};

// `browserSessionId` identifies the pre-login browser session. `identityAttempts` is your server-side store.
export async function startGoogleSignIn(browserSessionId: string) {
  const tenantKey = "example-workspace";
  const returnUrl = "https://app.example.com/auth/google/return";
  const { createGoogleIdentitySession: attempt } = await socialscope<{ createGoogleIdentitySession: IdentitySession }>(START_GOOGLE_IDENTITY, { input: { tenantKey, returnUrl } });

  // resultProof never leaves the server: not to the browser, a URL or a log.
  await identityAttempts.insert({
    id: attempt.id,
    browserSessionId,
    tenantKey,
    resultProof: attempt.resultProof,
    resultExpiresAt: attempt.resultExpiresAt,
    used: false,
  });

  return launchPage(attempt.launchUrl, attempt.launchToken, "/api/identity/redeem");
}

Errors you can get here:

Code Meaning What to do
CAPABILITY_UNAVAILABLE Google identity is not available in this SocialScope environment right now Hide the button. Readiness will say false too
FORBIDDEN Google identity is not enabled for your app, or the tenant is not yours Ask the admin
BAD_USER_INPUT The return URL is not exactly registered Fix the configured return URL
RATE_LIMITED Your app already has 20 unfinished attempts Wait extensions.retryAfterSeconds (1 to 600) before retrying

2. Send the browser

Use the same launch page as Connect, with the identity path. Serve it from the origin of your identity return URL.

// lib/socialscope-launch.ts
// Server only. Returns a standard Response, usable as-is in a Next.js route handler.
type LaunchPath = "/api/connect/redeem" | "/api/identity/redeem";

const ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ESCAPES[char]);

export function launchPage(launchUrl: string, launchToken: string, expectedPath: LaunchPath): Response {
  const connectOrigin = new URL(process.env.SOCIALSCOPE_GRAPHQL_URL!).origin;
  const launch = new URL(launchUrl);
  if (launch.origin !== connectOrigin || launch.pathname !== expectedPath || launch.search || launch.hash) {
    throw new Error("Unexpected SocialScope launch URL");
  }
  if (!/^[0-9a-f]{64}$/.test(launchToken)) throw new Error("Unexpected SocialScope launch token");

  const html = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Continuing</title></head>
<body>
<form method="post" action="${escapeHtml(launchUrl)}">
<input type="hidden" name="launchToken" value="${escapeHtml(launchToken)}">
<button type="submit">Continue</button>
</form>
<script>document.forms[0].submit()</script>
</body>
</html>`;

  return new Response(html, {
    headers: {
      "content-type": "text/html; charset=utf-8",
      "cache-control": "no-store",
      "referrer-policy": "strict-origin",
      "content-security-policy": `default-src 'none'; script-src 'unsafe-inline'; form-action ${connectOrigin}; base-uri 'none'`,
      "x-content-type-options": "nosniff",
    },
  });
}

SocialScope shows a page naming your app and asks the person to continue to Google. After Google, it redirects to your return URL with only ss_identity_id. No claims, code or token ever appear in the URL.

3. Redeem the result

At your return route, require the same browser session that started the attempt and match the hint to its saved unused attempt. Redeem once from your server with the saved tenant and proof, then mark the attempt used unless the status is PENDING, which does not consume the proof. The browser reaches this route through a cross-site redirect from SocialScope, so the pre-login cookie you check must be SameSite=Lax or None. A SameSite=Strict cookie is not sent on that request.

import { socialscope } from "./lib/socialscope";

const REDEEM_GOOGLE_IDENTITY = `
  mutation RedeemGoogleIdentity($id: ID!, $tenantKey: String!, $resultProof: String!) {
    redeemGoogleIdentityResult(id: $id, tenantKey: $tenantKey, resultProof: $resultProof) {
      status
      issuer
      googleSub
      email
      emailVerified
      name
      pictureUrl
    }
  }
`;

type IdentityResult = {
  status: "PENDING" | "COMPLETED" | "DENIED" | "FAILED" | "EXPIRED";
  issuer: string | null;
  googleSub: string | null;
  email: string | null;
  emailVerified: boolean | null;
  name: string | null;
  pictureUrl: string | null;
};

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export async function googleReturn(url: URL, browserSessionId: string) {
  const hints = url.searchParams.getAll("ss_identity_id");
  if (hints.length !== 1 || !UUID.test(hints[0])) return { outcome: "retry" as const };

  const saved = await identityAttempts.findUnused({ id: hints[0], browserSessionId });
  if (!saved || new Date(saved.resultExpiresAt) <= new Date()) return { outcome: "retry" as const };

  const { redeemGoogleIdentityResult: person } = await socialscope<{ redeemGoogleIdentityResult: IdentityResult }>(REDEEM_GOOGLE_IDENTITY, { id: saved.id, tenantKey: saved.tenantKey, resultProof: saved.resultProof });

  // PENDING does not consume the proof. Every other status does.
  if (person.status === "PENDING") return { outcome: "pending" as const };
  await identityAttempts.markUsed(saved.id);

  if (person.status !== "COMPLETED" || !person.issuer || !person.googleSub) {
    return { outcome: "not-signed-in" as const, status: person.status };
  }

  // Key the user by issuer + googleSub. Email can change and is never a linking key.
  const user = await users.findOrCreateByGoogle({
    issuer: person.issuer,
    googleSub: person.googleSub,
    email: person.email,
    emailVerified: person.emailVerified === true,
    name: person.name,
    pictureUrl: person.pictureUrl,
  });
  // Start your session only after the user write succeeded.
  return { outcome: "signed-in" as const, userId: user.id };
}

Rules:

  • Only COMPLETED with a non-empty issuer and googleSub can create or sign in a user. issuer is https://accounts.google.com.
  • Key your user by issuer plus Google subject in your own database. Email may change, so never link accounts by email alone.
  • Check emailVerified before treating the email as verified.
  • Create your app's session only after your own user write succeeds.
  • DENIED, FAILED and EXPIRED create no user. Offer a retry.
  • PENDING means the authorization is still running. Redeem again shortly, until the 20-minute result deadline.
  • The proof redeems once. A second redeem returns NOT_FOUND and must never sign anyone in.
  • Strip ss_identity_id from the URL before analytics or third-party scripts load.
  • If the browser session that started the attempt has expired, have the person start again rather than accepting the hint.

Statuses

Status Meaning Proof consumed
PENDING Still running and the 10-minute authorization deadline has not passed No
COMPLETED Verified. Claims are returned once Yes
DENIED The person cancelled at Google Yes
FAILED The Google exchange or verification failed Yes
EXPIRED The authorization deadline passed Yes

redeemGoogleIdentityResult returns NOT_FOUND for a second redeem, a wrong proof, a wrong tenant, another app's attempt, a result past its 20-minute deadline, or when identity was turned off for your app. These are deliberately indistinguishable.

Failure and recovery

What happened What your app sees What to do
The launch form was blocked or the tab closed No return. A redeem gives PENDING, then EXPIRED after 10 minutes Start a new attempt
The person cancelled at Google Return with the hint. Redeem gives DENIED Show cancelled. Offer a retry
Google's code exchange failed The browser may stay on SocialScope's error page and never return Redeem from your server with the saved attempt. You get FAILED or EXPIRED. Offer a retry
The person reloaded SocialScope's page after starting SocialScope says the request could not be verified Start again from your app
The network dropped during your redeem call The proof may already be consumed and the claims gone Start a new attempt
Identity was turned off mid-flow Later steps fail. Redeem gives NOT_FOUND Start again once it is re-enabled

What Google identity does not do

  • It does not create a SocialScope user or session. Your app owns both.
  • It does not grant YouTube access. Use Connect with YOUTUBE for that.
  • It does not store any Google access or refresh token.