Skip to Content
GuidesLogin with Foir

Login with Foir

Foir hosts the login UI for your storefront or customer-facing app. Your users authenticate once at auth.foir.dev, your app calls two GraphQL mutations, and you hold short-lived EdDSA access tokens against the public API — no password storage, no PKCE wrangling, no per-app login UI to build.

This page covers the customer-lane RPs (third-party apps that authenticate Foir customers). For the admin-lane flow used by back-office tools that authenticate Foir operators, the same mutation pair exists under adminLoginWithFoir / adminLoginWithFoirCallback.

When to use it

  • You’re shipping a storefront, mobile app, or third-party tool that needs to read or write per-customer data through Foir’s public API.
  • You don’t want to rebuild password reset, OTP, OAuth provider login, account verification, or session management.
  • You want one login surface across web + iOS + Android without rewriting it per platform.

If you only need anonymous public-API access (read published content, no per-customer data), an unscoped public API key is simpler — skip this page.

How it works

Your app api.foir.dev (GraphQL) auth.foir.dev │ │ │ │ customerLoginWithFoir │ │ │ → { redirectUrl } │ │ │─────────────────────────►│ │ │ 302 → redirectUrl ──────┼───────────────────────────►│ │ │ (Foir UI: pwd/OTP/SSO) │ │ 302 back with ?code=&state= ◄───────────────────────┤ │ customerLoginWithFoirCallback │ │ → { token, refreshToken, customerId, email } │ │─────────────────────────►│ │ │ query public API with Authorization: Bearer … │

Under the hood: OAuth 2.1 authorization code with PKCE (S256). The verifier never leaves the server, so unlike a browser-side PKCE flow it can’t be exfiltrated through XSS. Access tokens are 15-minute EdDSA JWTs signed by the customer-lane kid published at https://api.foir.dev/.well-known/jwks.json. Refresh tokens are 30-day opaque strings, rotated on every use, with reuse detection that revokes the whole family if anyone replays an old refresh.

Register your app

Register your app — a relying party — with the CLI’s config helper. Each relying party is scoped to one project:

// foir.config.ts import { defineConfig, defineRelyingParty, } from '@foir/cli/configs'; export default defineConfig({ name: 'Eide Clothing', relyingParties: [ defineRelyingParty({ clientId: 'eide-clothing-storefront', name: 'Eide Clothing — Storefront', redirectUris: [ 'https://eide.clothing/auth/callback', 'https://staging.eide.clothing/auth/callback', ], allowedScopes: ['profile'], }), ], });
foir push --tenant <tenant> --project <project>

foir push creates the relying party on first push and updates it on subsequent pushes. Disable one from the admin app (foir push does not delete relying parties; removal is a deliberate action in the UI).

Fields:

  • clientId — unique slug per project. Goes in your redirect URLs and identifies your app in the admin app.
  • name — display name shown on the hosted login page.
  • redirectUris — exact-match allow list. Trailing slashes count.
  • allowedScopes — OAuth scopes the relying party can request. Intersection wins (a request for more than the allow list yields just the overlap). Note: customer-token scopes are advisory today — access isn’t currently gated on the scopes claim. The field is here for OIDC convention and forward-compat. The only widely-recognised default is 'profile' (an OIDC standard scope); anything else is a project-specific string.
  • loginMethods (optional) — per-relying-party filter over the project’s customer login methods (e.g. disable password for this app only). It can narrow what the project allows, never widen it.
  • kind (optional) — which auth lane the RP belongs to: 'customer' (default) or 'admin'. foir push is project-scoped, so relying parties you declare in config are always customer-lane; admin relying parties are platform-managed and not part of project config, so you’ll normally leave this unset.

Branding (logo, primary color) lives on the project, not on individual RPs — multiple RPs in the same project share the same hosted-login look.

Grant your API key the scope

The mutations are gated behind the scope auth:login_with_foir:customer. Add it to whichever API key your storefront uses:

defineApiKey({ name: 'Eide Clothing Storefront (public)', keyType: 'public', envVar: 'PLATFORM_API_KEY', scopes: [ 'records:read', 'search:read', 'auth:login_with_foir:customer', // ← required for the mutations ], });

Public (pk_) keys are allowed because the storefront calls the mutations from the server (BFF / Worker) — but if you ever wanted to call them from the browser, that’s safe too: the redirect URL the server returns isn’t a secret.

The admin-lane equivalent scope is auth:login_with_foir:admin and is secret-key only — admin flows are server-side back-office integrations.

Integrate the two mutations

1. Begin the flow

mutation BeginLogin($input: LoginWithFoirStartInput!) { customerLoginWithFoir(input: $input) { redirectUrl } }
const {customerLoginWithFoir} = await foirClient.request(BEGIN_LOGIN, { input: { clientId: 'eide-clothing-storefront', redirectUri: 'https://eide.clothing/auth/callback', scopes: ['profile'], returnTo: searchParams.get('returnTo') ?? '/', }, }); throw redirect(customerLoginWithFoir.redirectUrl);

returnTo is round-tripped through the flow and surfaced on the callback response — useful for sending the customer back to the page they tried to reach when they hit the login wall.

2. Handle the callback

Foir 302s the browser back to your redirectUri with ?code=…&state=…. Pass both verbatim to the callback mutation:

mutation CompleteLogin($input: LoginWithFoirCallbackInput!) { customerLoginWithFoirCallback(input: $input) { token refreshToken customerId email returnTo } }
const url = new URL(request.url); const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const {customerLoginWithFoirCallback: session} = await foirClient.request( COMPLETE_LOGIN, {input: {code, state}}, ); setFoirSession(hydrogenSession, session); throw redirect(session.returnTo ?? '/');

That’s it. The whole PKCE dance — verifier generation, server-side state, code exchange — happened on Foir’s side. No sessionStorage, no manual URL construction, no code_verifier to track.

3. Call the public API

await fetch('https://api.foir.dev/graphql', { method: 'POST', headers: { authorization: `Bearer ${session.token}`, 'x-api-key': PUBLIC_API_KEY, 'content-type': 'application/json', }, body: JSON.stringify({query: '{ currentUser { id email } }'}), });

x-api-key still goes alongside Authorization — the public key identifies your app, the bearer token identifies the customer. Foir automatically scopes every request to the authenticated customer using the JWT’s customer_id claim.

4. Refresh before expiry

Access tokens are 15 minutes. Refresh tokens are 30 days, rotating on every use:

mutation CustomerRefreshToken($refreshToken: String!) { customerRefreshToken(refreshToken: $refreshToken) { token refreshToken customerId email } }

Reuse detection: if your app ever presents an already-rotated refresh token (someone replayed it, or the new one didn’t get saved), Foir revokes the entire token family. The next call fails with invalid_grant and the customer has to log in again. Save the new refresh token before responding to the user.

Admin lane (back-office integrations)

For a customer-owned back-office tool that wants to authenticate Foir operators against its own UI, the same pair exists:

mutation BeginAdminLogin($input: LoginWithFoirStartInput!) { adminLoginWithFoir(input: $input) { redirectUrl } } mutation CompleteAdminLogin($input: LoginWithFoirCallbackInput!) { adminLoginWithFoirCallback(input: $input) { token refreshToken adminUserId scopes isFirstPartyClient returnTo } }

Differences from the customer lane:

  • Admin relying parties are platform-managed — they aren’t registered through foir push. Contact Foir to set one up.
  • Scope auth:login_with_foir:admin is required — and is secret-key only.
  • The returned token’s aud is your client_id; the scopes reflect what the operator actually has access to (their granted roles for your app).

Verify tokens yourself (optional)

If your backend wants to verify access tokens without round-tripping the API:

import {jwtVerify, createRemoteJWKSet} from 'jose'; const jwks = createRemoteJWKSet( new URL('https://api.foir.dev/.well-known/jwks.json'), ); const {payload} = await jwtVerify(accessToken, jwks, { issuer: 'foir-customer', // or 'foir-admin' for the admin lane audience: 'eide-clothing-storefront', }); // payload.sub === customer_id // payload.tenant_id, payload.project_id, payload.scopes

Use this for backend-for-frontend services that hold the access token on behalf of the user — never ship the JWT verifier to the browser.

OIDC-standards-compliant clients

If you’re integrating from a generic OIDC library (Auth0 SDK, Spring Security, etc.) instead of GraphQL, the discovery document is published at:

  • https://api.foir.dev/customer/.well-known/openid-configuration
  • https://api.foir.dev/admin/.well-known/openid-configuration

The library handles authorization URL construction, code exchange, JWKS fetching, and token verification automatically. You still need to register an RP via foir.config.ts and grant the appropriate API-key scope.

Common questions

Why two mutations instead of one? OAuth’s design splits the flow at the browser redirect. The first mutation initiates and returns the redirect URL; the second one runs after the browser comes back. Combining them isn’t possible without holding the browser session open across the third-party login UI.

Can one customer hold multiple sessions across apps? Yes. Each client_id issues its own token chain; revoking access for one RP doesn’t affect another.

What if my redirect URI changes? Push the updated redirectUris list with foir push. Old URIs stop being accepted on the next authorize. There’s no grace period — coordinate with your deploy.

Can I read the customer’s email from the access token? No. The token carries sub (customer id), tenant_id, project_id, scopes, amr, jti. The callback mutation returns email once; cache it. For freshness, query the public API’s currentUser field with the token.

What happens on logout? Discard the tokens on your side; optionally call customerLogout to invalidate the family server-side. Discarding alone is enough for most apps — tokens self-expire.

Last updated on