Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Add Connect Apple Account to a Next.js App

Two App Router Route Handlers handle the sensitive work: the first generates a PKCE pair and state server-side, then redirects the browser to Figment's authorize endpoint; the second validates the returned state and exchanges the authorization code for a scoped grant. Your app never receives the user's Apple password—Apple account sign-in and trusted-device approval happen during the separate connection setup on Figment's hosted Mac, before this redirect flow begins.

How the flow fits together

Three parties take part: the user's browser, your Next.js server, and Figment's authorize endpoint.

  1. The user clicks Connect Apple Account in your app.
  2. Your server generates a fresh PKCE pair and an opaque state string, stores them in HTTP-only cookies, and redirects the browser to https://figment.so/apple-connect/authorize.
  3. The user signs into Figment, selects an existing Apple connection, and approves the requested scopes for your app.
  4. Figment redirects back to your registered callback URL with an authorization code and the same state string.
  5. Your server reads the cookies, confirms the state matches, and exchanges the code and verifier for a Figment access token, a refresh token, and a connection identifier.

Figment issues and validates this grant. The access token is a Figment credential, not an Apple password or Apple-issued token.

Prerequisites

Register your app. Visit figment.so/apple-connect/developers to register your app and obtain a client_id. Record the exact callback URL you plan to use—you will register it on that same page.

Store these values in .env.local and never commit them:

VariableWhat it holds
FIGMENT_CLIENT_IDClient ID from your app registration
FIGMENT_REDIRECT_URIExact callback URL you registered—e.g., https://yourapp.com/api/apple/callback
FIGMENT_SCOPESpace-separated scopes—e.g., messages:read or messages:read messages:send

Exact redirect URI matching. The URI in FIGMENT_REDIRECT_URI must match your registration character for character. RFC 9700 requires authorization servers to compare redirect URIs as literal strings; a trailing slash or http vs https mismatch will reject the request.

Initiate the connection

A plain anchor pointing at your Route Handler is enough to start the flow:

// app/settings/apple/page.tsx
export default function AppleSettingsPage() {
  return (
    <a href="/api/apple/connect">
      <button type="button">Connect Apple Account</button>
    </a>
  )
}

The Route Handler generates PKCE values and state server-side, never in the browser:

// app/api/apple/connect/route.ts
import { createHash, randomBytes } from 'node:crypto'
import { NextResponse } from 'next/server'

function base64url(buf: Buffer): string {
  return buf.toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '')
}

export async function GET() {
  // RFC 7636 §4.1: 43–128 unreserved chars; randomBytes(32) → 43-char base64url
  const verifier  = base64url(randomBytes(32))
  // RFC 7636 §4.2: S256 — base64url(SHA-256(verifier))
  const challenge = base64url(createHash('sha256').update(verifier).digest())
  const state     = base64url(randomBytes(16))

  const url = new URL('https://figment.so/apple-connect/authorize')
  url.searchParams.set('client_id',             process.env.FIGMENT_CLIENT_ID!)
  url.searchParams.set('response_type',         'code')
  url.searchParams.set('scope',                 process.env.FIGMENT_SCOPE!)
  url.searchParams.set('redirect_uri',          process.env.FIGMENT_REDIRECT_URI!)
  url.searchParams.set('code_challenge',        challenge)
  url.searchParams.set('code_challenge_method', 'S256')
  url.searchParams.set('state',                 state)

  const cookieOptions = {
    httpOnly: true,
    secure:   process.env.NODE_ENV === 'production',
    sameSite: 'lax' as const,
    maxAge:   300,   // 5 minutes; authorization codes are short-lived
    path:     '/',
  }
  const res = NextResponse.redirect(url)
  res.cookies.set('figment_verifier', verifier, cookieOptions)
  res.cookies.set('figment_state',    state,    cookieOptions)
  return res
}

randomBytes(32) base64url-encodes to 43 characters—the minimum length required by RFC 7636. NextResponse.redirect() lets you attach cookies to the response in a single return; a thrown redirect() in App Router does not carry response cookies.

Handle the callback

Figment delivers the code and state to your callback. Validate state before touching the code:

// app/api/apple/callback/route.ts
import { cookies }      from 'next/headers'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const code          = searchParams.get('code')
  const returnedState = searchParams.get('state')

  const jar           = await cookies()
  const expectedState = jar.get('figment_state')?.value
  const verifier      = jar.get('figment_verifier')?.value

  // Build both response objects before the state check so cookies
  // are cleared on every branch, including the error branch.
  const failUrl = new URL('/settings/apple?error=connect_failed', req.url)
  const failRes = NextResponse.redirect(failUrl)
  failRes.cookies.delete('figment_state')
  failRes.cookies.delete('figment_verifier')

  if (!code || !returnedState || !expectedState || !verifier ||
      returnedState !== expectedState) {
    return failRes
  }

  // Exchange the code and verifier for a Figment grant.
  const body = new URLSearchParams({
    grant_type:    'authorization_code',
    client_id:     process.env.FIGMENT_CLIENT_ID!,
    redirect_uri:  process.env.FIGMENT_REDIRECT_URI!,
    code,
    code_verifier: verifier,
  })

  const tokenRes = await fetch(
    'https://figment.so/api/apple-connect/oauth/token',
    {
      method:  'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body:    body.toString(),
    }
  )

  if (!tokenRes.ok) return failRes

  const { access_token, refresh_token, connection_id, expires_in } =
    await tokenRes.json()

  // Identify the authenticated user from your session, then persist
  // access_token, refresh_token, connection_id, and expires_in in your
  // database. access_token expires in expires_in seconds (normally 900).
  // Store the new refresh_token after every refresh—it rotates.
  await saveGrantForUser({ access_token, refresh_token, connection_id, expires_in })

  const successUrl = new URL('/settings/apple?connected=1', req.url)
  const successRes = NextResponse.redirect(successUrl)
  successRes.cookies.delete('figment_state')
  successRes.cookies.delete('figment_verifier')
  return successRes
}

Clearing both cookies before branching prevents a stale verifier or state from persisting if the exchange fails. Per RFC 9700, PKCE serves as CSRF protection for public clients; the opaque state value adds a session-binding layer tied to the specific connect attempt.

Token storage. saveGrantForUser is a placeholder for your database layer. Store both tokens and the connection_id per user. access_token expires in 900 seconds—refresh it with grant_type=refresh_token before the next API call, and store the new refresh_token immediately.

Display connection status

After a successful connect, call GET /v1/connections/{connection_id} with the user's current access token. The response shape is {connection: {id, mode, services, ...}}; each entry in connection.services is an object with a state field:

// app/settings/apple/_components/ConnectionBadge.tsx
// Server Component — no 'use client' directive

const LABELS: Record<string, string> = {
  connecting:         'Connecting…',
  needs_approval:     'Needs approval',
  syncing:            'Syncing',
  ready:              'Connected',
  offline:            'Offline',
  reconnect_required: 'Reconnect required',
  revoked:            'Access revoked',
}

type Props = { connectionId: string; accessToken: string }

export async function ConnectionBadge({ connectionId, accessToken }: Props) {
  const res = await fetch(
    `https://figment.so/api/apple-connect/v1/connections/${connectionId}`,
    {
      headers: { Authorization: `Bearer ${accessToken}` },
      cache:   'no-store',
    }
  )

  if (!res.ok) return <span>Status unavailable</span>

  const { connection } = await res.json()
  // connection.services contains only the services covered by the grant.
  // Each entry: { state, reason, observedAt }
  const messagesState: string = connection.services?.messages?.state ?? 'unknown'
  return (
    <span data-status={messagesState}>
      {LABELS[messagesState] ?? messagesState}
    </span>
  )
}

Pass a refreshed access token from your session layer—tokens expire in 900 seconds, so refresh before the fetch if the stored token is near expiry. The endpoint returns a non-2xx status for an expired or revoked token; on failure, direct the user to /apple-connect/account to reconnect rather than retrying silently.

A needs_approval state means the connection is waiting for a trusted-device confirmation on the Mac. A revoked or reconnect_required state means the user removed access or the connection needs re-establishing. Users can remove any app's access at any time via account.apple.com under Sign-In and Security (Apple support 121539).

Security rules that matter

Use S256, not plain. S256 hashes the verifier with SHA-256 before transmitting the challenge; plain transmits the verifier directly and removes interception protection. The initiate handler sets code_challenge_method: 'S256' explicitly—never omit it.

Generate fresh values per attempt. Never reuse a verifier or state across connect attempts. If a user opens two tabs, each must carry independently generated values. The state cookie's 5-minute maxAge limits the window a stale cookie remains valid.

Revocation propagates immediately. Revoking one app grant stops that grant. Disconnecting the Apple connection at the Figment level stops every grant and queued operation for that connection. When the badge shows revoked or reconnect_required, send the user back through the connect flow.


For the full picture of which Apple services Figment can reach once a connection is active, see OAuth for iCloud: Connect Apple Data to Your App. To go deeper on PKCE parameters—verifier entropy, challenge encoding, and downgrade prevention—see Use PKCE for an Apple Data Connection Flow.

Register your app and start building at figment.so/apple-connect/developers.


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms