# Faable — Full Documentation Text > The complete documentation for Faable (Faable Deploy + Faable Auth), concatenated as a single document for LLM ingestion. European cloud platform with zero-ops hosting and a multi-tenant identity server, hosted 100% in Europe under GDPR. Faable Deploy builds and runs a GitHub repository as a public HTTPS service with no Dockerfile and no CI configuration. Common workloads: REST and JSON APIs in Python (Flask, FastAPI, Django) and Node.js (Express, NestJS); LLM apps and AI chatbots with streaming responses (SSE passes through unbuffered; the 60-second limit is on time-to-first-byte, not response length); webhook endpoints (Stripe, GitHub Apps) and WhatsApp, Telegram, Discord and Slack bots, all driven by inbound HTTP, in Node.js or Python; and web apps in Next.js, Vite and static frameworks. Supported runtimes: Node.js 20, 22, 24 and Python 3.11, 3.12. # Overview --- ## Platform Source: https://faable.com/docs # Welcome to the Faable Cloud Platform **Faable** is a comprehensive cloud platform built to empower developers. From zero-configuration deployments to robust, secure authentication, Faable provides the infrastructure you need to build, deploy, and scale modern applications with ease. Start by creating an account on the [Faable Dashboard](https://dashboard.faable.com) to access and manage your projects. ## 📦 Platform Products Our ecosystem is composed of two primary cloud products, each designed to solve specific challenges in the software development lifecycle. ### 🚀 [Faable Deploy](/deploy/get-started.md) A zero-config CI/CD platform that seamlessly builds and hosts applications directly from your linked Git repositories. - **Automated Deployments:** Just link your Git repository and let Faable handle the continuous integration and deployment workflow. - **Custom Domains & SSL:** Configure custom domains effortlessly, complete with free, automated SSL certificates. - **Advanced Security:** Built-in Web Application Firewall (WAF) to keep your workloads safe without manual configuration. 👉 **[Dive into Faable Deploy](/deploy/get-started.md)** ### 🔐 [Faable Auth](/auth/get-started.md) A scalable, multi-tenant identity server that securely manages user authentication and authorization. - **Seamless Integrations:** Easy-to-use quickstarts for frameworks like Next.js and React Native. - **Social Login:** Out-of-the-box support for federated identity providers like Google, Facebook, and Apple. - **Standardized Protocols:** Full support for OAuth 2.0, OpenID Connect (OIDC), and Machine-to-Machine (M2M) communication. 👉 **[Dive into Faable Auth](/auth/get-started.md)** --- ## Frequently Asked Questions Source: https://faable.com/docs/faq # Frequently Asked Questions Direct answers to the most common questions about Faable. For details, follow the links in each answer. ## What is Faable? **Faable is a European cloud platform that combines two products in one subscription: [Faable Deploy](deploy/get-started.md), a zero-config platform that builds and hosts apps straight from a Git repository, and [Faable Auth](auth/get-started.md), a multi-tenant OAuth 2.0 / OpenID Connect identity server.** It lets developers ship and secure full-stack apps and AI agents without managing infrastructure. ## Is Faable hosted in Europe? Is it GDPR-compliant? **Yes. Faable is hosted 100% in Europe with full GDPR data sovereignty** — your apps and user identities stay in European datacenters under European law. This is a core difference from US platforms that offer optional EU regions. ## How much does Faable cost? **Faable uses one subscription covering both Deploy and Auth, with three platform tiers: Free (0 €), Hobby (15 €/month), and Pro (99 €/month).** Pricing is flat — each tier is a fixed monthly fee, and what the tier includes comes with it. Exactly one resource on the platform is metered: egress above the plan allowance, and only on Pro (Free includes 10 GB/month and Hobby 50 GB/month, neither of them metered). Plans apply per project. See the [pricing page](platform/pricing.md) for full details. ## Is there a free tier? **Yes. The Free plan costs 0 € and lets you run a real app end-to-end**: up to 3 projects per account, one `bi.xs` instance per project, up to 10 successful deployments per day, 10 GB of egress per month, and Faable Auth included. A brand-new account starts with 2 projects and gets the third 48 hours after signing up. Upgrade to [Hobby](platform/pricing.md) for the full instance catalog, unlimited deployments, and custom domains — and because plans are per project, moving one project to Hobby also frees a Free slot for another. ## Can I deploy directly from GitHub? **Yes. Connect your GitHub repository, push your code, and Faable detects your stack, builds it, and serves it at `https://.faable.link` with free automatic SSL** — no Dockerfiles, YAML, or servers to provision. See [What is zero-config CI/CD?](deploy/what-is-zero-config-cicd.mdx) and [Get Started](deploy/get-started.md). ## Is Faable Deploy a PaaS? **Yes — Faable Deploy is a Platform-as-a-Service (PaaS) for full backend applications.** It runs long-lived server processes — Django, FastAPI, Flask, Express, Next.js with SSR — in managed Linux containers, not just static sites or short-lived serverless functions. Builds, releases, autoscaling, SSL, the WAF, logs and custom domains are all handled by the platform; you only push code. Think of the Heroku/Render category of platform, hosted 100% in Europe with authentication included in the same subscription. ## What frameworks and stacks does Faable Deploy support? **Faable Deploy runs any stack that listens on the `$PORT` environment variable in a Linux container.** It auto-detects **Node.js** (20, 22, 24) — including Next.js and Express — and **Python** (3.10, 3.11, 3.12, 3.13) — including Django, FastAPI, and Flask — plus static frontends (Astro, Vite, Angular, Gatsby, CRA, Vue). Anything else deploys from your own `Dockerfile`. Your server binds to `0.0.0.0` on `$PORT` and Faable handles the rest. See the guides for [Next.js](deploy/guides/guide-next.md), [Express](deploy/guides/guide-express.md), [Django](deploy/guides/guide-django.md), [FastAPI](deploy/guides/guide-fastapi.md) and [Flask](deploy/guides/guide-flask.md), or the full [build requirements](deploy/build-requirements.mdx). ## Does Faable Auth replace Auth0, Clerk, or Keycloak? **For most use cases, yes. Faable Auth is a standards-based OAuth 2.0 / OpenID Connect identity server** covering social login, passwordless (magic link and email OTP), RBAC and teams, custom domains, actions and webhooks, and first-class machine-to-machine tokens — with migration guides from [Auth0](auth/guides/migrate-from-auth0.mdx), [Clerk](auth/guides/migrate-from-clerk.mdx) and [Keycloak](auth/guides/migrate-from-keycloak.mdx). **One thing is not available yet: SAML / pre-built enterprise SSO connectors.** If your project needs SAML today, Faable Auth is not the right fit yet. [Two-step verification](auth/mfa.md) — authenticator apps, security keys and passkeys — is built in. See the [comparison](auth/compare.mdx) for the full feature-by-feature table. ## How is Faable Auth priced — what is MAU? **Faable Auth entitlements are sized by monthly active users (MAU): the number of distinct users who authenticate in a calendar month.** Each platform tier includes an MAU allowance — 250 on Hobby, 10,000 pooled on Pro — and there is no per-user line on your invoice: going beyond the allowance is a reason to talk to us, handled case by case, not an automatic charge. There are no per-feature add-ons either. See [Faable Auth pricing](auth/pricing.md). ## Does Faable include a Web Application Firewall? **Yes. Faable Deploy includes a built-in Web Application Firewall (WAF) powered by the OWASP Core Rule Set, protecting against the OWASP Top 10** (injection, XSS, and more). It's included rather than a paid add-on — see the [WAF docs](deploy/security-waf.md). ## Are Deploy and Auth sold separately? **No. Both products are covered by a single Faable subscription** — one platform fee unlocks Deploy and Auth together, with one support relationship. See [pricing](platform/pricing.md). ## Where is my data stored? **All Faable data — application workloads and user identities — is stored in European datacenters under GDPR.** This applies to both Faable Deploy and Faable Auth. ## Related - [Get Started with Faable Deploy](deploy/get-started.md) · [Get Started with Faable Auth](auth/get-started.md) - [Pricing](platform/pricing.md) - [Faable Deploy vs Vercel, Render & Railway](deploy/compare.mdx) · [Faable Auth vs Auth0, Clerk & Keycloak](auth/compare.mdx) # Faable Deploy --- ## Get Started Source: https://faable.com/docs/deploy/get-started # Get Started with Faable Deploy **Deploy your app straight from Git. No infrastructure to manage. Live in minutes.** Connect your repository, push your code, and Faable detects your framework, builds it, and ships it to a public URL with free automatic SSL. Faable Deploy is a **Platform-as-a-Service (PaaS) for full backend applications**: it runs long-lived server processes — Django, FastAPI, Flask, Express, Next.js with SSR — in managed containers, not just static sites or edge functions. Databases aside, everything your backend needs to serve production traffic (builds, releases, scaling, SSL, WAF, logs, custom domains) is part of the platform. > Curious what happens after you push? See [How deployment works](how-it-works.mdx). Comparing platforms? See [Faable Deploy vs Vercel, Render & Railway](compare.mdx). ## How Faable Deploy is structured Three concepts model the whole product: - **Account** — your billing and team boundary on Faable. - **Apps** — one app per repo (or one per environment, e.g. `staging` / `production`). Each app gets `.faable.link` plus optional [custom domains](domains/custom-domain.md). - **Instances** — how much compute your app runs on. Pick a size from the [catalog](pricing.md#compute-catalog) (`bi.xs` through `bi.2xlarge`) — start small and scale up whenever you need to. ## Deploy your first app 1. In the **[Faable Dashboard](https://dashboard.faable.com)**, create a **Project** and an **App**. 2. Open the app and click **Link repository**. Pick your GitHub org and repo (install the **Faable GitHub App** if prompted). Faable connects your repo and sets up automatic deploys for you. 3. **Push to your release branch.** Faable detects your framework, builds your app, and takes it live at `https://.faable.link`. That's it — nothing to configure, no tokens or YAML to write. > [!NOTE] > Linked an empty repository? Just push your first commit. Faable deploys from the repository's default branch, so whether your first push creates `main` or `master`, the app picks it up. You can change the deploy branch at any time from the app page. > [!TIP] > Faable detects your framework automatically and, if your project has a build step, runs it for you before every deploy. Need multi-environment setups (staging/preview/production) or custom build commands? See the [advanced deploy options](github-actions.md). ## Deploy from your laptop with the CLI For ad-hoc deploys (local testing, debugging) install the CLI and log in. Your repository is already connected to the app — you did that in the first step of the app creation form — so the CLI resolves the app automatically: ```bash npm i -g @faable/faable faable login faable deploy # deploy current repository ``` > [!TIP] > Got `Request failed with status code 404` on `faable deploy`? The repository isn't linked to any app yet. Link it from the dashboard (**Link repository**) and deploy again. ## What's next | Topic | What you'll learn | | ---------------------------------------------- | ----------------------------------------------------------------------- | | **[Your own CI](github-actions.md)** | Deploying from GitHub Actions: multi-environment, custom steps. | | **[Runtime](runtime.md)** | Supported Node versions, environment variables, the app restart policy. | | **[Custom Domains](domains/custom-domain.md)** | Map `app.example.com` to your app with auto-renewed SSL. | | **[Security & WAF](security-waf.md)** | The built-in Web Application Firewall that ships with every app. | | **[CLI reference](../cli.md)** | Every flag and command the CLI supports. | ### Framework guides End-to-end walkthroughs for the stacks Faable detects automatically: | Guide | Stack | | ----------------------------------------------------- | ------------------------------------------- | | **[Deploy Node.js Express](guides/guide-express.md)** | Node.js 20 / 22 / 24 | | **[Deploy Django](guides/guide-django.md)** | Python + gunicorn, static files, migrations | | **[Deploy FastAPI](guides/guide-fastapi.md)** | Python + uvicorn, async workers | | **[Deploy Flask](guides/guide-flask.md)** | Python + gunicorn, application factory | ## Pricing & limits See **[Deploy pricing](pricing.md)** for the instance catalog and bandwidth allowances, and the [unified platform pricing](../platform/pricing.md) for plans and support tiers. --- ## How deployment works Source: https://faable.com/docs/deploy/how-it-works # How deployment works **You push your code. Faable does the rest.** No pipelines to wire up, no infrastructure to manage — from a Git push to a live website in minutes. Faable Deploy is a Platform-as-a-Service (PaaS) for **full backend applications**: real server processes (Django, FastAPI, Express, Next.js SSR) running in managed containers — not just static hosting. ```mermaid flowchart TD A["📤 Git push"] --> B["🔍 Faable detects your framework"] B --> C["🏗️ Faable builds your app"] C --> D["🚀 Faable deploys it"] D --> E["🌍 Your website is live"] ``` ## What happens at each step **📤 You push your code.** Commit to your release branch, like you always do. That push is all it takes to ship. **🔍 Faable detects your framework.** We look at your project and figure out how it's built — Next.js, React, Vue, Astro, a Node or Python backend, and more. Nothing to configure. **🏗️ Faable builds your app.** We run your build for you and package everything your app needs to run. **🚀 Faable deploys it.** Your new version goes out with free automatic SSL and a built-in Web Application Firewall — nothing to set up. **🌍 Your website is live.** Your app is online at `https://.faable.link`, ready to share. Every future push ships the same way. ## Ready to deploy? Link your repository and push your first app in minutes — see [Get Started](get-started.md). Want to map your own domain? See [Custom Domains](domains/custom-domain.md). --- ## Deploy a Next.js App Source: https://faable.com/docs/deploy/guides/guide-next # Deploy a Next.js App ▲ **Push your Next.js project to GitHub and Faable builds and runs it — no Dockerfile, no YAML, no server config.** The builder detects Next.js from your repo, runs `next build`, and serves it behind automatic SSL at `https://.faable.link`, hosted 100% in Europe. ## What Faable detects Detection is file-based ([full rules](../build-requirements.mdx)). For a Next.js project the builder needs: | It looks for | Which gives it | | :--------------------------------------------- | :----------------------------------------- | | `package.json` with `next` in the dependencies | The framework → build and start commands | | A `build` script (the standard `next build`) | Run at build time, with your env available | With both present the builder runs `npm run build` and starts the app with the standard `next start` — no configuration from you. Repeat builds reuse a persistent build cache, so they are significantly faster than the first one. ## The one thing to get right: `$PORT` Faable assigns your app a port at runtime and passes it as the `PORT` environment variable. **`next start` honors `PORT` out of the box, so a standard Next.js project needs no change at all.** It only matters if you run a custom server — bind `0.0.0.0` and `process.env.PORT`, never a hardcoded port. ## Standalone output — automatic Faable builds your app with Next.js [standalone output](https://nextjs.org/docs/app/api-reference/config/next-config-js/output): instead of shipping the full `.next` + `node_modules` (often over 1 GB), the deployed artifact contains `server.js` plus only the modules your app actually imports — typically **~150 MB**. Smaller artifacts mean faster deploys and faster cold starts. This is applied automatically and falls back to the full output when your config isn't compatible (a custom `distDir`, your own `output` setting…). To opt out explicitly: ```json // faable.json { "next": { "standalone": false } } ``` Declaring your own `startCommand` also disables it — you took control of how the container starts. ## Environment variables Two kinds, and the difference matters in Next.js: - **`NEXT_PUBLIC_*`** are inlined into the client bundle **at build time**. Set them as [environment variables](../environment.mdx) **before** pushing — a variable added after the build won't appear in the browser until the next deploy. - **Server-side variables** (database URLs, API keys) are read at runtime and picked up on the next restart. ```bash faable deploy secrets set NEXT_PUBLIC_API_URL=https://api.example.com DATABASE_URL=postgres://… ``` ## Monorepos (Turborepo, npm workspaces) If your Next.js app lives in a subdirectory (`apps/web`), set the **Root Directory** on the app — in the dashboard app settings, or in `faable.json`: ```json { "rootDir": "apps/web" } ``` Faable installs at the workspace root (so hoisted dependencies resolve) and builds the app in its subdirectory, with file tracing rooted correctly for standalone output. ## Choosing the Node.js version The default is the platform's current Node LTS. Pin one with `engines` in `package.json`: ```json { "engines": { "node": "22.x" } } ``` Any valid semver range works; the builder resolves it to a concrete release. ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your Next.js repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop, for an ad-hoc deploy: ```bash npm i -g @faable/faable faable login faable deploy ``` Your app is live at `https://.faable.link` with automatic SSL and the [WAF](../security-waf.md) already inspecting traffic. ## Troubleshooting - **A `NEXT_PUBLIC_*` variable is `undefined` in the browser** — it was set after the build. Set it and push again (build-time inlining is how Next.js works, not a Faable limitation). - **The app boots then receives no traffic** — a custom server hardcodes its port. Bind `0.0.0.0` and `process.env.PORT`. - **Build fails on a missing dependency that exists at the repo root** — set the Root Directory (see monorepos above) so the workspace install runs at the root. - **Standalone didn't apply** — a custom `distDir` or `output` in `next.config.*` makes the builder fall back to the full profile. The app still deploys; the artifact is just larger. - **Build succeeds locally but fails on Faable** — check the Node version: pin your local major with `engines.node`. ## FAQ ### Do I need a Dockerfile to deploy Next.js on Faable? No. Faable detects Next.js from the `next` dependency in `package.json`, runs `next build`, and starts it with `next start` — with standalone output applied automatically. A Dockerfile is only the escape hatch for stacks the buildpacks don't recognise. ### How does Faable set the Next.js standalone output? Automatically, for platform builds: the builder enables `output: "standalone"` and packages `server.js` with the traced module subset (~150 MB vs 1 GB+). Opt out with `{"next": {"standalone": false}}` in `faable.json`. ### Why is my NEXT_PUBLIC_ variable not showing up? `NEXT_PUBLIC_*` variables are inlined into the JavaScript bundle when `next build` runs. Set them before deploying; changing one requires a new deploy to take effect in the browser. ### Can I deploy a Next.js app from a Turborepo monorepo? Yes. Set the Root Directory to the app's subdirectory (dashboard setting or `rootDir` in `faable.json`). Faable installs at the workspace root and builds the app in its subdirectory. ### Which Node.js versions does Faable support for Next.js? The default is the platform's current Node LTS. Pin any version with `engines.node` in `package.json` (e.g. `"22.x"`); the builder resolves it to a concrete release. ## Related - [What the Builder Expects](../build-requirements.mdx) — full detection and start-command rules - [Deploy Django](guide-django.md) · [Deploy FastAPI](guide-fastapi.md) · [Deploy Flask](guide-flask.md) · [Deploy Node.js Express](guide-express.md) - [Environment & Releases](../environment.mdx) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [Add authentication to your app](../../auth/get-started.md) — Faable Auth is included in the same subscription --- ## Deploy a Node.js Express App Source: https://faable.com/docs/deploy/guides/guide-express # Deploy a Node.js Express App **Push your Express project to GitHub and Faable builds and runs it — no Dockerfile, no YAML.** The builder detects Node.js from your `package.json`, installs your dependencies, runs your build step if you have one, and serves the app behind automatic SSL at `https://.faable.link`, hosted 100% in Europe. Supported Node.js versions: **20, 22 and 24**. ## Give your app a `start` script Make sure your app has a `start` script inside `package.json` ```json { "name": "app_name", "scripts": { "start": "" } } ``` ## Serve your app at `$PORT` Start a server that listens on `0.0.0.0` and serves `http` traffic using environment variable `PORT` to configure its port binding. This variable will be automatically configured in Faable Cloud when routing requests to your app. ```js const express = require('express') const app = express() // NOTE: To work on Faable Cloud, use the $PORT environment variable const port = process.env.PORT app.get('/', (req, res) => { res.send('Hello World!') }) app.listen('0.0.0.0', port, () => { console.log(`Example app listening on port ${port}`) }) ``` ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your Express repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop, for an ad-hoc deploy: ```bash npm i -g @faable/faable faable login faable deploy ``` Your app is ready at `https://.faable.link`, with automatic SSL and the [WAF](../security-waf.md) already inspecting traffic. ## Environment variables Read configuration from `process.env` and set it with the [CLI](../../cli.md#secrets) or the dashboard: ```bash faable deploy secrets set DATABASE_URL=postgres://… NODE_ENV=production ``` Faable also injects `PORT`, `FAABLE_APP_ID`, `FAABLE_RELEASE` and `FAABLE_GIT_COMMIT`. See [Environment & Releases](../environment.mdx). ## Troubleshooting - **Requests time out** — the server binds a hardcoded port instead of `process.env.PORT`, or it listens on `127.0.0.1` instead of `0.0.0.0`. - **Build fails on `npm ci`** — `package-lock.json` is out of sync with `package.json`. Commit an updated lockfile. - **The app exits right after boot** — a missing environment variable usually throws at startup; check the deployment logs in the dashboard. ## Related - [What the Builder Expects](../build-requirements.mdx) — detection rules, Node.js versions, the `$PORT` contract - [Deploy a WhatsApp Bot](guide-whatsapp-bot.md) · [Deploy a Telegram Bot](guide-telegram-bot.md) — webhook bots on this stack - [Deploy Next.js](guide-next.md) · [Deploy Django](guide-django.md) · [Deploy FastAPI](guide-fastapi.md) · [Deploy Flask](guide-flask.md) - [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [Add authentication to your app](../../auth/get-started.md) — Faable Auth is included in the same subscription --- ## Deploy a Vite App Source: https://faable.com/docs/deploy/guides/guide-vite # Deploy a Vite App **Push your Vite project to GitHub and Faable builds and serves it — no Dockerfile, no YAML.** The builder detects Vite from your `package.json`, installs your dependencies, runs `npm run build`, and serves the contents of `dist/` from Faable's shared static runtime behind automatic SSL at `https://.faable.link`, hosted 100% in Europe. It works the same whether your Vite app is React, Vue, Svelte, Solid or Preact — detection keys on the `vite` dependency, not on the UI framework. ## What Faable does with a Vite repo Nothing to configure. The builder: 1. Detects `vite` in your `dependencies` **or** `devDependencies`. 2. Installs your dependencies (including dev ones — `vite` itself lives there). 3. Runs your `build` script. 4. Ships **only `dist/`** to the static runtime: no Node.js process boots, nothing is installed at runtime, and the deploy is over in seconds. 5. Turns on **SPA fallback** — unknown paths rewrite to `index.html`, so React Router, Vue Router and friends work on a hard refresh. ## Do not add a `start` script This is the one thing that changes the outcome. **A `start` script disables static serving entirely.** The builder trusts it: if `package.json` defines one, Faable stops generating a serve command and runs `npm run start` in a Node.js container instead. That is correct for a project that ships a real server — and wrong for a Vite SPA, where `start` is almost always just the preview server: ```json { "scripts": { "build": "vite build", "start": "vite preview" // ← binds 127.0.0.1:4173 } } ``` `vite preview` with no flags listens on **localhost, port 4173**. Inside a container nothing outside can reach it, the health check never passes, and the deployment fails as a startup crash — after a build that went perfectly green. Pick one: - **Recommended — delete the `start` script.** Faable serves `dist/` statically, which is what a Vite SPA wants. - Or, if you want to keep it, make it honour the platform contract: ```json { "scripts": { "start": "vite preview --host 0.0.0.0 --port $PORT" } } ``` This works, but you pay for a Node.js process to serve static files. See [Start command precedence](../build-requirements.mdx#-nodejs-projects) for the full rule. ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your Vite repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop, for an ad-hoc deploy: ```bash npm i -g @faable/faable faable login faable deploy ``` Your app is ready at `https://.faable.link`, with automatic SSL and the [WAF](../security-waf.md) already inspecting traffic. ## Environment variables Vite variables are **compiled into your bundle at build time**, not read at runtime, and only the ones prefixed `VITE_` are exposed to your code. Two consequences: - Set them **before** you build, with the [CLI](../../cli.md#secrets) or the dashboard, then redeploy — changing a variable does not affect an already-built app. - Anything in a `VITE_` variable is **public**. It ships inside the JavaScript your visitors download. Never put an API secret there; call a backend instead. ```bash faable deploy secrets set VITE_API_URL=https://api.example.com ``` See [Environment & Releases](../environment.mdx). ## A different output directory Faable expects the Vite default, `dist/`. If your `vite.config.js` sets `build.outDir` to something else, the build fails the post-build check with `Static build output 'dist' not found after the build`. Either drop the override, or force the static buildpack and point it at your directory in `faable.json`: ```json { "buildpack": "static", "static": { "spa": true } } ``` ## Monorepos If the Vite app lives in a subdirectory, point `rootDir` at it in `faable.json` at the repository root: ```json { "rootDir": "apps/web" } ``` The builder installs from the workspace root (so hoisted dependencies resolve) and builds in your subdirectory. See [Monorepos](../build-requirements.mdx#monorepos-root-directory). ## Troubleshooting - **The deployment fails as a startup crash right after a green build** — you have a `start` script. See [above](#do-not-add-a-start-script). - **`Static build output 'dist' not found`** — your `build.outDir` is not `dist/`, or the build wrote nothing. - **Every route except `/` 404s** — this should not happen on Vite (SPA fallback is on by default). If you forced the `static` buildpack, add `{ "static": { "spa": true } }`. - **A `VITE_` variable is empty in the browser** — it was set after the build. Redeploy. - **`npm ci` fails** — `package-lock.json` is out of sync with `package.json`. Commit an updated lockfile. ## Related - [What the Builder Expects](../build-requirements.mdx) — detection rules, static frontends, the `$PORT` contract - [Deploy Astro](guide-astro.md) — the static sibling, without SPA fallback - [Deploy Next.js](guide-next.md) · [Deploy Node.js Express](guide-express.md) - [Migrate from Vercel](migrate-from-vercel.mdx) · [Migrate from Netlify](migrate-from-netlify.mdx) - [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [Add authentication to your app](../../auth/get-started.md) — Faable Auth is included in the same subscription --- ## Deploy an Astro Site Source: https://faable.com/docs/deploy/guides/guide-astro # Deploy an Astro Site **Push your Astro project to GitHub and Faable builds and serves it — no Dockerfile, no YAML.** The builder detects Astro from your `package.json`, installs your dependencies, runs `npm run build`, and serves the contents of `dist/` from Faable's shared static runtime behind automatic SSL at `https://.faable.link`, hosted 100% in Europe. Astro has two output modes, and **they deploy differently**. Which one you are on decides everything below, so check `astro.config.mjs` first. ## Static Astro — the default If your config has no `output` (or `output: 'static'`), `astro build` writes real HTML files per page into `dist/`. That is the zero-config path: 1. The builder detects `astro` in your `dependencies` or `devDependencies`. 2. It installs, runs your `build` script, and ships **only `dist/`** to the static runtime — no Node.js process boots. 3. **SPA fallback stays off.** Astro builds a real file per route, so an unknown path 404s instead of rewriting to `index.html`. That is the correct behaviour for a multi-page site; it is not a bug. Nothing else to do. Push and it is live. ## Server-rendered Astro — add a `start` script If your config sets `output: 'server'` or `output: 'hybrid'` with an adapter such as [`@astrojs/node`](https://docs.astro.build/en/guides/integrations-guide/node/), `astro build` writes something else entirely: ``` dist/ ├── client/ ← assets └── server/ └── entry.mjs ← your server ``` **There is no `index.html` at the root of `dist/`.** If you deploy this with no `start` script, the build goes green, Faable serves `dist/` as a static directory, and every route — the homepage included — 404s. It looks like a successful deploy that serves nothing. Add a `start` script so the builder runs your server instead of serving files: ```json { "scripts": { "build": "astro build", "start": "node ./dist/server/entry.mjs" } } ``` A `start` script switches the app to a Node.js container and disables static serving — which is exactly what you want here. ### Bind to `0.0.0.0` The `@astrojs/node` standalone server reads `HOST` and `PORT` from the environment. Faable injects `PORT`; it does **not** set `HOST`, and the adapter's default is localhost, which nothing outside the container can reach. Set it once: ```bash faable deploy secrets set HOST=0.0.0.0 ``` Or configure it in the adapter and skip the variable. Either way, the rule is the [`$PORT` contract](../build-requirements.mdx#-the-port-contract): listen on `0.0.0.0` at `process.env.PORT`. ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your Astro repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop, for an ad-hoc deploy: ```bash npm i -g @faable/faable faable login faable deploy ``` Your site is ready at `https://.faable.link`, with automatic SSL and the [WAF](../security-waf.md) already inspecting traffic. ## Environment variables Astro exposes variables prefixed `PUBLIC_` to client-side code, **compiled into the bundle at build time**. So: - Set them **before** the build, with the [CLI](../../cli.md#secrets) or the dashboard, then redeploy. - A `PUBLIC_` variable is **public** — it ships inside the JavaScript your visitors download. Never put a secret there. ```bash faable deploy secrets set PUBLIC_SITE_URL=https://example.com ``` On a **static** site there is no server, so non-`PUBLIC_` variables only exist during the build. On a **server-rendered** site they are also readable at runtime through `import.meta.env` / `process.env`. See [Environment & Releases](../environment.mdx). ## A different output directory Faable expects the Astro default, `dist/`. If `astro.config.mjs` sets `outDir` elsewhere, the build fails the post-build check with `Static build output 'dist' not found after the build`. Drop the override, or force the static buildpack in `faable.json`: ```json { "buildpack": "static" } ``` ## Monorepos If the Astro site lives in a subdirectory, point `rootDir` at it in `faable.json` at the repository root: ```json { "rootDir": "apps/site" } ``` See [Monorepos](../build-requirements.mdx#monorepos-root-directory). ## Troubleshooting - **The build is green but every route 404s** — you are on `output: 'server'` with no `start` script, so a server build is being served as static files. See [above](#server-rendered-astro--add-a-start-script). - **A sub-page 404s on a static site** — that page was not built. Check your routes and any `getStaticPaths`. Astro deliberately does not rewrite unknown paths to `index.html`. - **Requests time out on a server-rendered site** — the adapter is bound to localhost. Set `HOST=0.0.0.0`. - **`Static build output 'dist' not found`** — `outDir` is not `dist/`, or the build wrote nothing. - **A `PUBLIC_` variable is empty** — it was set after the build. Redeploy. ## Related - [What the Builder Expects](../build-requirements.mdx) — detection rules, static frontends, the `$PORT` contract - [Deploy Vite](guide-vite.md) — the SPA sibling, with rewrite-to-index on - [Deploy Next.js](guide-next.md) · [Deploy Node.js Express](guide-express.md) - [Migrate from Vercel](migrate-from-vercel.mdx) · [Migrate from Netlify](migrate-from-netlify.mdx) - [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [Add authentication to your app](../../auth/get-started.md) — Faable Auth is included in the same subscription --- ## Deploy a Django App Source: https://faable.com/docs/deploy/guides/guide-django # Deploy a Django App 🐍 **Push your Django project to GitHub and Faable builds and runs it — no Dockerfile, no YAML, no `gunicorn` command to write.** The builder detects Django from your repo, installs your dependencies, and starts your app behind automatic SSL at `https://.faable.link`, hosted 100% in Europe. ## What Faable detects Detection is file-based ([full rules](../build-requirements.mdx)). For a Django project the builder needs two things: | It looks for | Which gives it | | :-------------------------------------------------- | :---------------------------------------------- | | `requirements.txt`, `pyproject.toml` or `Pipfile` | Your dependencies — installed into a virtualenv | | `manage.py` **plus** a package containing `wsgi.py` | Django itself → the start command below | When both are present the builder runs, with no configuration from you: ```bash gunicorn .wsgi:application --bind 0.0.0.0:$PORT ``` `gunicorn` is installed automatically if it isn't in your dependencies — though pinning it in `requirements.txt` is good practice. ## The one thing to get right: `$PORT` Faable assigns your app a port at runtime and passes it as the `PORT` environment variable. The auto-detected start command already binds `0.0.0.0:$PORT`, so **a standard Django project needs no change at all**. You only have to think about it if you override the start command — in which case bind `0.0.0.0` and `$PORT`, never a hardcoded port. ## Prepare your project ### 1. Dependencies ```txt # requirements.txt Django>=5.0 gunicorn whitenoise psycopg[binary] # if you use PostgreSQL ``` ### 2. Allowed hosts and CSRF Django refuses requests whose `Host` header isn't in `ALLOWED_HOSTS`. Read it from the environment so the same code works locally and on Faable: ```python # settings.py import os ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",") or [] # Django 4+ also checks the origin on unsafe requests CSRF_TRUSTED_ORIGINS = [ o for o in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") if o ] ``` Then set them as [environment variables](../environment.mdx) on the app: ```bash faable deploy secrets set \ ALLOWED_HOSTS=myapp.faable.link \ CSRF_TRUSTED_ORIGINS=https://myapp.faable.link ``` Add your [custom domain](../domains/custom-domain.md) to both values when you attach one. ### 3. Static files `gunicorn` serves your application, not your static assets. The standard solution is [WhiteNoise](https://whitenoise.readthedocs.io/), which serves them from the app process itself: ```python # settings.py import os STATIC_URL = "static/" STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", # right after SecurityMiddleware # … the rest of your middleware ] STORAGES = { "staticfiles": { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", }, } ``` Collect them at build time with a `buildCommand` in `faable.json`: ```json { "buildCommand": "python manage.py collectstatic --noinput" } ``` ### 4. Secrets and debug Never ship `DEBUG = True` or a hardcoded `SECRET_KEY`: ```python SECRET_KEY = os.environ["SECRET_KEY"] DEBUG = os.environ.get("DEBUG", "0") == "1" ``` ```bash faable deploy secrets set SECRET_KEY=$(python -c "import secrets;print(secrets.token_urlsafe(50))") ``` ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your Django repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop, for an ad-hoc deploy: ```bash npm i -g @faable/faable faable login faable deploy ``` Your app is live at `https://.faable.link` with automatic SSL and the [WAF](../security-waf.md) already inspecting traffic. ## Database migrations Migrations are **not** run automatically — an automatic migration on every deploy is a good way to lose data. Run them explicitly when a release needs them, from your machine against the production database: ```bash DATABASE_URL= python manage.py migrate ``` Faable Deploy does not provide a managed database; point `DATABASE_URL` at your own PostgreSQL (any EU provider) and set it as a secret. ## Choosing the Python version The default is Python 3.11. Supported minors are **3.10, 3.11, 3.12 and 3.13** — pin one with a `runtime.txt`: ```txt python-3.12 ``` Or with `.python-version`, or `requires-python` in `pyproject.toml`. First match wins, in that order. ## Troubleshooting - **Build fails resolving Django with a `Requires-Python` error** — Django 6.x requires Python ≥3.12, and the default on Faable is 3.11. Pin the version: add a `.python-version` file containing `3.12` (or `runtime.txt` with `python-3.12`) and push again. - **`DisallowedHost` in the logs** — `ALLOWED_HOSTS` doesn't include the domain you're visiting. Add `.faable.link` and your custom domain. - **CSS and images 404** — static files weren't collected or WhiteNoise isn't in the middleware. Check both steps above. - **Build fails with "no start command"** — the builder found your dependencies but not `manage.py` + a `wsgi.py` package. Either fix the layout or declare `startCommand` in `faable.json`. - **`CSRF verification failed` on forms** — add your full origin (with `https://`) to `CSRF_TRUSTED_ORIGINS`. - **The app boots then exits** — you overrode the start command with a hardcoded port. Bind `0.0.0.0:$PORT`. ## FAQ ### Do I need a Dockerfile to deploy Django on Faable? No. Faable detects Django from `manage.py` plus a `wsgi.py` package and generates the `gunicorn` start command for you. A `Dockerfile` is only the escape hatch for stacks the buildpacks don't recognise — and note that if your repo has a `package.json`, that wins over the Dockerfile. ### How do I run `collectstatic` on Faable? Set it as the `buildCommand` in `faable.json` so it runs at build time: `{"buildCommand": "python manage.py collectstatic --noinput"}`. Serve the result with WhiteNoise. ### Which Python versions does Faable support for Django? 3.10, 3.11, 3.12 and 3.13. The default is 3.11; pin the one you want with `runtime.txt` (`python-3.13`), `.python-version`, or `requires-python` in `pyproject.toml`. Note that Django 6.x requires Python ≥3.12, so on the latest Django you must pin `3.12` or `3.13` explicitly. ### Does Faable run my Django migrations automatically? No, by design — automatic migrations on every deploy risk irreversible schema changes. Run `python manage.py migrate` explicitly against your production `DATABASE_URL` when a release needs it. ### Can I use Celery or background workers? Faable Deploy runs **web services** — a process that serves HTTP on `$PORT`. A separate worker process type isn't supported today, so background jobs need an external runner or an in-process scheduler. ## Related - [What the Builder Expects](../build-requirements.mdx) — full detection and start-command rules - [Deploy a WhatsApp Bot](guide-whatsapp-bot.md) · [Deploy a Telegram Bot](guide-telegram-bot.md) — webhook bots on this stack - [Deploy Next.js](guide-next.md) · [Deploy FastAPI](guide-fastapi.md) · [Deploy Flask](guide-flask.md) · [Deploy Node.js Express](guide-express.md) - [Environment & Releases](../environment.mdx) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [Add authentication to your app](../../auth/get-started.md) — Faable Auth is included in the same subscription --- ## Deploy a FastAPI App Source: https://faable.com/docs/deploy/guides/guide-fastapi # Deploy a FastAPI App ⚡ **Push your FastAPI project to GitHub and Faable builds and runs it — no Dockerfile, no YAML, no `uvicorn` command to write.** The builder detects FastAPI from your repo, installs your dependencies, and serves it behind automatic SSL at `https://.faable.link`, hosted 100% in Europe. ## What Faable detects Detection is file-based ([full rules](../build-requirements.mdx)). For FastAPI the builder needs: | It looks for | Which gives it | | :------------------------------------------------ | :---------------------------------------------- | | `requirements.txt`, `pyproject.toml` or `Pipfile` | Your dependencies — installed into a virtualenv | | A module defining `app = FastAPI(...)` | The ASGI entrypoint → the start command below | It searches for that module in this order, preferring the file that actually defines `app`: `main.py` → `app.py` → `asgi.py` → `wsgi.py` → `application.py` → `server.py` → `app/main.py` → `app/app.py` → `src/main.py` Then it runs, with no configuration from you: ```bash uvicorn :app --host 0.0.0.0 --port $PORT ``` `uvicorn` is installed automatically if it isn't in your dependencies — though pinning it is good practice. ## The one thing to get right: `$PORT` Faable assigns your app a port at runtime and passes it as the `PORT` environment variable. The auto-detected command already binds `0.0.0.0` and `$PORT`, so a standard project needs no change. It matters only if you override the start command: ```python # ✅ Correct — for local runs; on Faable the platform passes the port import os import uvicorn if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=int(os.environ.get("PORT", 8000))) ``` Never hardcode `port=8000` in the command Faable runs — the app would start and receive no traffic. ## A minimal deployable app ```python # main.py from fastapi import FastAPI app = FastAPI() @app.get("/") def read_root(): return {"status": "ok"} @app.get("/healthz") def healthz(): return {"ok": True} ``` ```txt # requirements.txt fastapi uvicorn[standard] ``` That is a complete, deployable repo — two files. ## Configuration and secrets Read configuration from the environment and set it with the [CLI](../../cli.md#secrets) or the dashboard: ```python import os DATABASE_URL = os.environ["DATABASE_URL"] ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "").split(",") ``` ```bash faable deploy secrets set DATABASE_URL=postgres://… ALLOWED_ORIGINS=https://myapp.faable.link ``` Faable also injects `PORT`, `FAABLE_APP_ID`, `FAABLE_RELEASE` and `FAABLE_GIT_COMMIT` — useful for `/healthz` payloads and error reports. See [Environment & Releases](../environment.mdx). ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your FastAPI repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop: ```bash npm i -g @faable/faable faable login faable deploy ``` Your API is live at `https://.faable.link` with automatic SSL and the [WAF](../security-waf.md) already inspecting traffic. Interactive docs are at `/docs` if you left them enabled. ## Workers and concurrency The auto-detected command runs a single uvicorn process, which is the right default: FastAPI is async, so one process handles many concurrent requests, and scaling up is usually a matter of picking a bigger [instance](../pricing.md#compute-catalog). If your workload is CPU-bound and you want multiple workers, declare it explicitly in `faable.json`: ```json { "startCommand": "uvicorn main:app --host 0.0.0.0 --port $PORT --workers 4" } ``` Only do this on an instance with enough CPU and RAM for the extra processes. ## Choosing the Python version The default is Python 3.11. Supported minors are **3.10, 3.11, 3.12 and 3.13** — pin one with a `runtime.txt`: ```txt python-3.12 ``` Or with `.python-version`, or `requires-python` in `pyproject.toml`. First match wins, in that order. ## Troubleshooting - **Build fails with "no start command"** — the builder installed dependencies but found no module defining `app = FastAPI(...)` in the search list. Rename your entrypoint to `main.py`, or set `startCommand` in `faable.json`. - **The app starts but requests time out** — the start command binds a hardcoded port instead of `$PORT`. - **CORS errors from your frontend** — add `CORSMiddleware` and drive its origins from an environment variable. - **`ModuleNotFoundError` at boot** — the dependency isn't in `requirements.txt`; the build environment installs only what the manifest declares. - **A `Dockerfile` is being used instead of the Python buildpack** — detection order puts `package.json` (Node) above Python and Python above `Dockerfile`. Force it with `{"buildpack": "python"}` in `faable.json`. ## FAQ ### Do I need a Dockerfile to deploy FastAPI on Faable? No. Faable finds the module that defines `app = FastAPI(...)` and generates the `uvicorn` command for you. A `Dockerfile` is the escape hatch for stacks the buildpacks don't detect natively. ### How does Faable know which file my FastAPI app is in? It checks `main.py`, `app.py`, `asgi.py`, `wsgi.py`, `application.py`, `server.py`, `app/main.py`, `app/app.py` and `src/main.py`, preferring the one that actually defines `app`. If yours lives elsewhere, set `startCommand` in `faable.json`. ### Can I run FastAPI with multiple uvicorn workers? Yes — set `startCommand` in `faable.json` with `--workers N`. The default is a single process, which suits most async workloads; add workers only on an instance sized for them. ### Which Python versions does Faable support for FastAPI? 3.10, 3.11, 3.12 and 3.13. The default is 3.11; pin your choice with `runtime.txt` (`python-3.13`), `.python-version`, or `requires-python` in `pyproject.toml`. ### Does the built-in WAF interfere with API clients? The WAF ships in monitor mode conventions described in the [WAF guide](../security-waf.md): review the logs before switching to block mode, because machine clients sometimes send payloads that trip generic OWASP rules. Exclusions are scoped per path, parameter or rule. ## Related - [What the Builder Expects](../build-requirements.mdx) — full detection and start-command rules - [Deploy a WhatsApp Bot](guide-whatsapp-bot.md) · [Deploy a Telegram Bot](guide-telegram-bot.md) — webhook bots on this stack - [Deploy Next.js](guide-next.md) · [Deploy Django](guide-django.md) · [Deploy Flask](guide-flask.md) · [Deploy Node.js Express](guide-express.md) - [Environment & Releases](../environment.mdx) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [Secure your API with OAuth 2.0](../../auth/oauth-flows/client-credentials.mdx) — Faable Auth is included in the same subscription --- ## Deploy a Flask App Source: https://faable.com/docs/deploy/guides/guide-flask # Deploy a Flask App 🌶️ **Push your Flask project to GitHub and Faable builds and runs it — no Dockerfile, no YAML, no `gunicorn` command to write.** The builder detects Flask from your repo, installs your dependencies, and serves it behind automatic SSL at `https://.faable.link`, hosted 100% in Europe. ## What Faable detects Detection is file-based ([full rules](../build-requirements.mdx)). For Flask the builder needs: | It looks for | Which gives it | | :------------------------------------------------ | :---------------------------------------------- | | `requirements.txt`, `pyproject.toml` or `Pipfile` | Your dependencies — installed into a virtualenv | | A module defining `app = Flask(...)` | The WSGI entrypoint → the start command below | It searches for that module in this order, preferring the file that actually defines `app`: `main.py` → `app.py` → `asgi.py` → `wsgi.py` → `application.py` → `server.py` → `app/main.py` → `app/app.py` → `src/main.py` Then it runs, with no configuration from you: ```bash gunicorn :app --bind 0.0.0.0:$PORT ``` `gunicorn` is installed automatically if it isn't in your dependencies — though pinning it is good practice. ## The one thing to get right: `$PORT` Faable assigns your app a port at runtime and passes it as the `PORT` environment variable. The auto-detected command already binds `0.0.0.0:$PORT`, so a standard project needs no change. Note that `app.run()` is Flask's **development** server — Faable never calls it, and you shouldn't either in production. Keep it guarded for local use: ```python import os if __name__ == "__main__": # Local development only. On Faable, gunicorn serves the app. app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000))) ``` ## A minimal deployable app ```python # app.py import os from flask import Flask app = Flask(__name__) @app.get("/") def index(): return {"status": "ok"} @app.get("/healthz") def healthz(): return {"ok": True} ``` ```txt # requirements.txt Flask>=3.0 gunicorn ``` That is a complete, deployable repo — two files. ## A JSON API, end to end Most Flask projects deployed on Faable are REST APIs — a service another app or a bot calls over HTTP. Here is a complete one with the pieces that are easy to get wrong in production: JSON errors instead of HTML, CORS for a browser client, config from the environment, and a health check. ```txt # requirements.txt Flask>=3.0 flask-cors>=5.0 gunicorn ``` ```python # app.py import os from flask import Flask, jsonify, request from flask_cors import CORS from werkzeug.exceptions import HTTPException app = Flask(__name__) # Only the origins you actually serve — never "*" on an API that takes a key. CORS(app, resources={r"/v1/*": {"origins": os.environ.get("ALLOWED_ORIGINS", "").split(",")}}) API_KEY = os.environ["API_KEY"] @app.before_request def require_key(): if request.path.startswith("/v1/") and request.headers.get("X-API-Key") != API_KEY: return jsonify(error="unauthorized"), 401 @app.get("/v1/items") def list_items(): # Page from the query string, clamped — an unbounded limit is how APIs fall over. limit = min(int(request.args.get("limit", 20)), 100) return jsonify(items=fetch_items(limit), limit=limit) @app.post("/v1/items") def create_item(): body = request.get_json(silent=True) or {} if not body.get("name"): return jsonify(error="name is required"), 422 return jsonify(item=save_item(body)), 201 @app.get("/healthz") def healthz(): return {"ok": True} @app.errorhandler(HTTPException) def json_errors(error): """Flask returns HTML error pages by default — clients want JSON.""" return jsonify(error=error.name, status=error.code), error.code @app.errorhandler(Exception) def unhandled(error): app.logger.exception("unhandled error") return jsonify(error="internal server error", status=500), 500 ``` Two details that matter once it's live: - **Log to stdout.** `app.logger` goes to the deployment logs in the dashboard and to `faable deploy logs`. Don't write log files — the filesystem is ephemeral. - **Don't hold state in memory.** A module-level cache or counter is lost on every deploy, and on every wake from sleep. Use a database — see [Databases](databases.md). ## Configuration and secrets Read configuration from the environment, never from committed files: ```python import os app.config["SECRET_KEY"] = os.environ["SECRET_KEY"] app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DATABASE_URL") ``` Set them with the [CLI](../../cli.md#secrets) or the dashboard: ```bash faable deploy secrets set SECRET_KEY=… DATABASE_URL=postgres://… ``` Faable also injects `PORT`, `FAABLE_APP_ID`, `FAABLE_RELEASE` and `FAABLE_GIT_COMMIT`. See [Environment & Releases](../environment.mdx). ## Static files and templates Flask serves its own `static/` folder and Jinja templates from the app process, so both work unchanged — no extra configuration. For a heavier asset pipeline (a bundled frontend), build it at deploy time with a `buildCommand` in `faable.json`: ```json { "buildCommand": "npm ci && npm run build" } ``` ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your Flask repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop: ```bash npm i -g @faable/faable faable login faable deploy ``` Your app is live at `https://.faable.link` with automatic SSL and the [WAF](../security-waf.md) already inspecting traffic. ## The application factory pattern If you use `create_app()` instead of a module-level `app`, the builder can't detect an entrypoint — it looks for an assigned `app` object. Either expose one: ```python # wsgi.py from myapp import create_app app = create_app() ``` …or declare the start command yourself in `faable.json`: ```json { "startCommand": "gunicorn 'myapp:create_app()' --bind 0.0.0.0:$PORT" } ``` ## Workers The default is a single gunicorn worker. Flask is synchronous, so a CPU-bound or slow-I/O app benefits from more: ```json { "startCommand": "gunicorn app:app --bind 0.0.0.0:$PORT --workers 4" } ``` Size the worker count to the [instance](../pricing.md#compute-catalog) you picked — more workers on a small instance will make things worse, not better. ## Choosing the Python version The default is Python 3.11. Supported minors are **3.10, 3.11, 3.12 and 3.13** — pin one with a `runtime.txt`: ```txt python-3.12 ``` Or with `.python-version`, or `requires-python` in `pyproject.toml`. First match wins, in that order. ## Troubleshooting - **Build fails with "no start command"** — no module in the search list defines `app = Flask(...)`. Use the factory pattern above or set `startCommand`. - **The app runs locally but 502s on Faable** — you're relying on `app.run()`. Production is served by gunicorn; make sure the app object is importable at module level. - **`Working outside of application context`** — code touching `current_app` or the database runs at import time. Move it inside a request handler or an app-context block. - **`ModuleNotFoundError` at boot** — the dependency isn't in `requirements.txt`. - **Requests time out** — the start command binds a hardcoded port instead of `$PORT`. ## FAQ ### Do I need a Dockerfile to deploy Flask on Faable? No. Faable finds the module that defines `app = Flask(...)` and generates the `gunicorn` command for you. A `Dockerfile` is the escape hatch for stacks the buildpacks don't detect natively. ### How do I deploy a Flask app that uses an application factory? Expose a module-level `app` (for example in `wsgi.py` with `app = create_app()`), or set `startCommand` in `faable.json` to `gunicorn 'myapp:create_app()' --bind 0.0.0.0:$PORT`. ### Does Faable use `app.run()` to start my Flask app? No — `app.run()` is the development server. Faable starts your app with gunicorn bound to `0.0.0.0:$PORT`. Keep `app.run()` behind an `if __name__ == "__main__"` guard for local development. ### How many gunicorn workers should I run? One by default. Increase it with `startCommand` only if your workload is CPU-bound or blocking, and size the count to the instance you picked from the [catalog](../pricing.md#compute-catalog). ### Which Python versions does Faable support for Flask? 3.10, 3.11, 3.12 and 3.13. The default is 3.11; pin your choice with `runtime.txt` (`python-3.13`), `.python-version`, or `requires-python` in `pyproject.toml`. ## Related - [What the Builder Expects](../build-requirements.mdx) — full detection and start-command rules - [Deploy a WhatsApp Bot](guide-whatsapp-bot.md) · [Deploy a Telegram Bot](guide-telegram-bot.md) — webhook bots on this stack - [Deploy Next.js](guide-next.md) · [Deploy Django](guide-django.md) · [Deploy FastAPI](guide-fastapi.md) · [Deploy Node.js Express](guide-express.md) - [Environment & Releases](../environment.mdx) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [Add authentication to your app](../../auth/get-started.md) — Faable Auth is included in the same subscription --- ## Deploy a PHP App Source: https://faable.com/docs/deploy/guides/guide-php # Deploy a PHP App 🐘 **Push your PHP project to GitHub and Faable builds and runs it — no Dockerfile, no YAML, no start command to write.** Your app is served by **Apache with mod_php**, so the `.htaccess` you already have keeps working, behind automatic SSL at `https://.faable.link`, hosted 100% in Europe. Two shapes are supported and both are zero-config: a **plain PHP** project (the `index.html` + `login.php` layout you'd drop into `htdocs`) and a **composer** project such as **Laravel** or **Symfony**, with its front controller in `public/`. ## What Faable detects Detection is file-based ([full rules](../build-requirements.mdx)): | It looks for | Which gives it | | :------------------------------------------ | :-------------------------------------------------- | | `composer.json` | A PHP project → `composer install` before packaging | | Any `.php` in the repo root or in `public/` | A PHP project with nothing to install (plain PHP) | Then Apache is pointed at the first of these that holds an `index.php`, falling back to the repository root: | Your layout | Document root | | :------------------------------------------ | :------------------ | | `public/index.php` — Laravel, Symfony, Slim | `public/` | | `public_html/index.php` | `public_html/` | | `web/index.php` | `web/` | | `.php` files in the repository root | the repository root | > [!NOTE] > A `Dockerfile` beside your PHP project wins: the app keeps building from your own image. That is deliberate — PHP repositories that already ship one are running on it, and detection never re-platforms them. Force `"buildpack": "php"` in `faable.json` to take the managed runtime instead. ## The `$PORT` contract: nothing to do Faable passes the port as the `PORT` environment variable, and the PHP runtime already binds Apache to it. Unlike Node or Python, there is no start command for you to write and nothing to change in your code. ## A minimal deployable app ``` my-site/ ├── index.php ├── login.php └── css/style.css ``` ```php .faable.link` with automatic SSL and the [WAF](../security-waf.md) already inspecting traffic. ## Laravel, end to end A stock Laravel repository deploys with no changes to your code — but Laravel's own defaults assume a database is sitting next to the app, and on Faable it isn't. That's the whole of the work. ### What the builder does for you ```bash composer install --no-dev --optimize-autoloader --no-interaction --no-progress ``` Your composer scripts run, so `artisan package:discover` executes and `vendor/` ships with the app. The document root is `public/`, which means `.env`, `storage/` and `vendor/` sit **outside** the web root where they belong. ### The environment it needs A fresh Laravel ships `APP_KEY=` empty and points sessions, cache and queues at a database it expects to find locally: ```ini APP_KEY= APP_DEBUG=true DB_CONNECTION=sqlite SESSION_DRIVER=database CACHE_STORE=database QUEUE_CONNECTION=database ``` Set them as [secrets](../runtime.md) before your first deploy. Generate a key locally with `php artisan key:generate --show`: ```bash faable deploy secrets set \ "APP_KEY=base64:…" \ APP_ENV=production \ APP_DEBUG=false ``` Then pick one of two paths: **Without a database yet** — good enough to get the app up and see it working: ```bash faable deploy secrets set SESSION_DRIVER=cookie CACHE_STORE=file QUEUE_CONNECTION=sync ``` **With a database** — the real setup. Point Laravel at a managed database and keep its defaults: ```bash faable deploy secrets set \ DB_CONNECTION=pgsql \ DB_HOST=… DB_PORT=5432 DB_DATABASE=… DB_USERNAME=… DB_PASSWORD=… ``` Both `pgsql` and `mysql` work — the runtime ships `pdo_pgsql`, `pdo_mysql` and `mysqli`. See [Databases & SQLite](databases.md) for EU-hosted providers with free tiers. ### Migrations There is no shell on the running app, so run migrations from your machine against the same managed database: ```bash php artisan migrate --force ``` ### What to leave alone - **`APP_DEBUG=false`.** With debug on, a stack trace — including your environment — is served to whoever triggers the error. - **`storage/`** is writable, but per-instance and temporary: logs and compiled views live there happily, user uploads do not (see below). - Don't commit `.env`. It is never served, but a repository is not a secret store. ## Choosing the PHP version Supported: **8.2, 8.3 and 8.4**. Resolution order, first match wins: 1. `.php-version` 2. `config.platform.php` in `composer.json` 3. `require.php` in `composer.json` 4. Default: `8.3` Your **`composer.lock` also has a vote**: `composer install` obeys the lock, so if a locked package needs a newer PHP than your `composer.json` asks for, Faable resolves the higher version for you. A brand-new Laravel is exactly that case — it declares `"php": "^8.3"` while locking packages that require `>= 8.4.1`, and deploys on 8.4 without you doing anything. To pin explicitly, add a `.php-version` file: ``` 8.4 ``` ## What's in the runtime On top of what the official PHP image includes (`curl`, `mbstring`, `openssl`, `session`, `sqlite3`/`pdo_sqlite`, `xml`…), the runtime ships: `bcmath` · `exif` · `gd` · `intl` · `mysqli` · `opcache` · `pdo_mysql` · `pdo_pgsql` · `zip` `mod_rewrite`, `mod_headers` and `mod_expires` are enabled, so `.htaccess` works as written. Defaults worth knowing: `memory_limit` 256M, `max_execution_time` 60s, `upload_max_filesize` and `post_max_size` 32M. **`display_errors` is off.** Errors never reach your visitors; they go to the deploy logs with file and line: ```bash faable deploy logs ``` An app that needs an extension outside that list ships its own `Dockerfile` (Hobby or Pro). ## Files that are never served When the repository root is your document root, everything beside your PHP would otherwise be downloadable. The runtime denies dotfiles (`.env` first of all), `.sql`/`.sqlite`/`.db`/`.log`/`.ini`/`.sh`/`.yml` files, `composer.json`/`composer.lock`, `package.json`, `faable.json`, `Dockerfile`, `Procfile`, and the `vendor/`, `node_modules/` and `.git/` directories. Directory listings are off. It is still better to keep non-public files out of the document root entirely — which is what a `public/` layout gives you for free. ## Data and files: what does not survive Two things trip up PHP projects more than anything else, and neither shows up as a build error — the deploy succeeds and the app misbehaves later: **There is no database next to your app.** `new mysqli("localhost", "root", "", "my_db")` — the XAMPP/WAMP default — has nothing to connect to. Use a managed database and read the credentials from the environment: ```php $db = new mysqli( getenv('DB_HOST'), getenv('DB_USER'), getenv('DB_PASSWORD'), getenv('DB_NAME') ); ``` The build log warns you when it spots a localhost connection in your sources, so you find out at deploy time instead of from a blank page. **The filesystem is ephemeral.** Conventional write targets (`uploads/`, `storage/`, `writable/`, `var/`, `bootstrap/cache/`…) are made writable when the app starts, which is what caches and temporary work need. But those writes live in the running instance only: they are lost on restart, on sleep/wake and on every deploy, and two instances never see each other's files. Anything a user uploads belongs in object storage (S3-compatible) or a database — never on disk. ## Troubleshooting **A blank 500 on every page.** That's PHP failing with `display_errors` off, which is the correct production posture. `faable deploy logs` has the fatal with its file and line. **"Database connection failed".** The app is trying to reach a database on `localhost`. Point it at a managed one (above). **The build fails with "Problem 1… Problem 17" from composer.** Your `composer.lock` needs a PHP version the app isn't getting. Pin it with `.php-version`, or re-resolve the lock on the version you want with `composer update`. **"This app would run on PHP X, but its installed dependencies require PHP Y".** The same thing, caught before shipping: the artifact would have run on a PHP its own `vendor/` cannot boot. The message names the package that raised the bar. **WordPress.** Not supported, and the deploy says so rather than shipping a broken site: WordPress needs a MySQL database beside the app and a persistent filesystem for `wp-content/uploads`, and Faable gives an app neither. ## FAQ ### Do I need a Dockerfile to deploy PHP on Faable? No. PHP is a managed buildpack like Node and Python: push a repository with a `composer.json` or with `.php` files and Faable detects it, installs your dependencies and serves it with Apache. Dockerfiles remain available on Hobby and Pro for apps that need an extension or a stack we don't ship. ### Which PHP versions does Faable support? 8.2, 8.3 and 8.4, with 8.3 as the default. Pin one in `.php-version` or in `composer.json`. Faable also reads your `composer.lock`, so a project whose locked dependencies need a newer PHP gets it automatically. ### Does `.htaccess` work on Faable? Yes. The runtime is Apache with mod_php and `AllowOverride All`, with `mod_rewrite`, `mod_headers` and `mod_expires` enabled — the rewrite rules in Laravel's `public/.htaccess`, or in a hand-written LAMP app, are honoured as written. ### Can I deploy Laravel on Faable? Yes, with no changes to your code. Set `APP_KEY`, `APP_ENV=production` and `APP_DEBUG=false` as secrets, and either connect a managed database or switch sessions, cache and queues off the database driver — Laravel's defaults expect a database that Faable does not run beside your app. The walkthrough is [above](#laravel-end-to-end). ### Why does my Laravel app say "Composer detected issues in your platform"? Your `vendor/` was resolved for a newer PHP than the app is running. Faable now reads `composer.lock` when picking the version, so this resolves itself on a fresh deploy; if you pinned a version explicitly in `.php-version`, either raise the pin or re-resolve the lock with `composer update`. ### Can I use MySQL with a PHP app on Faable? Yes — any database reachable over the network. The runtime ships `mysqli`, `pdo_mysql` and `pdo_pgsql`. What it does not do is run a database next to your app, so a connection to `localhost` will fail. See [Databases & SQLite](databases.md). ### Where do file uploads go? Into the running instance's filesystem, which is temporary: uploads are lost on restart, on sleep/wake and on every deploy. For anything users expect to find later, upload to S3-compatible object storage or store it in your database. ## Related - **[What the Builder Expects](../build-requirements.mdx)** — the detection rules in full, for every stack. - **[Databases & SQLite](databases.md)** — where your data should live, with EU-hosted free tiers. - **[Runtime](../runtime.md)** — environment variables, secrets, restarts and logs. - **[Security & WAF](../security-waf.md)** — what Faable blocks at the edge before it reaches your app. --- ## Deploy a WhatsApp Bot Source: https://faable.com/docs/deploy/guides/guide-whatsapp-bot # Deploy a WhatsApp Bot 💬 **A WhatsApp bot is an HTTPS webhook — and that is exactly what Faable Deploy runs best.** Push a repo with a small web server in it, and Faable builds it, gives it a public HTTPS URL with a valid certificate at `https://.faable.link`, and keeps it behind a [WAF](../security-waf.md). Meta requires a public HTTPS endpoint with a trusted certificate to deliver messages; you get one on the first deploy, with nothing to configure. This guide covers the [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api) — Meta's official webhook product — in **Node.js** and **Python**. ## Webhooks, not long-polling This is the one architectural decision that matters, so make it first. Faable Deploy scales an app to zero once no HTTP request has arrived for a while — **30 minutes on the Free plan, 2 hours on Hobby and Pro** — and wakes it on the next one. That model fits a webhook bot perfectly: WhatsApp delivers a message, the request wakes your app, your app replies. Between conversations it costs you nothing, and on Hobby and Pro a bot with steady traffic never stops at all. It does **not** fit a bot that holds a socket open and polls — the pattern used by unofficial libraries like Baileys or `whatsapp-web.js`, which pair with a phone and keep a WebSocket alive: | | Cloud API webhook | Socket / pairing libraries | | :--------------------------- | :------------------------------------ | :--------------------------------------- | | How messages arrive | Inbound HTTPS POST | Long-lived outbound WebSocket | | Survives scale-to-zero | Yes — the request wakes the app | No — the socket dies when the app sleeps | | Session state | Stateless, a token in the environment | An auth folder on disk that must persist | | Officially supported by Meta | Yes | No | Faable's filesystem is **ephemeral**: every deploy starts from a fresh container, and a sleeping app loses whatever it wrote locally. A pairing library stores its session in a folder, so it re-pairs on every deploy and drops off whenever the app sleeps — and because it receives no inbound requests, nothing keeps it awake on any plan. If you already have a bot built this way, move it to the Cloud API before you deploy it here — otherwise you are fighting the platform. ## What Faable detects Detection is file-based ([full rules](../build-requirements.mdx)): - **Node.js** — a `package.json` with a `start` script. Supported versions: 20, 22 and 24 — pin one with `engines.node`. - **Python** — a `requirements.txt`, `pyproject.toml` or `Pipfile`, plus a module defining your app object (`app.py` with `app = Flask(...)` is found automatically). Supported versions: 3.10, 3.11, 3.12 and 3.13. Both are zero-config. No Dockerfile, no YAML. ## Serve on `$PORT` Faable assigns your app a port and passes it as the `PORT` environment variable. Bind `0.0.0.0` and read it — a hardcoded port means requests time out and Meta marks your webhook as failing. ## Verify the signature, on the raw body Meta signs every webhook delivery with your app's **App secret**: the header `X-Hub-Signature-256` carries `sha256=`. Verify it, or anyone who finds your URL can inject fake messages. The one thing that breaks this for most people: **the signature is over the exact bytes Meta sent**. If your framework parses the JSON and you hash the re-serialised object, key order and whitespace differ and the check fails forever. Both examples below keep the raw body and hash that. Compare in constant time (`crypto.timingSafeEqual`, `hmac.compare_digest`) — a plain `===` leaks timing. ## Node.js Two files. `package.json`: ```json { "name": "whatsapp-bot", "type": "module", "engines": { "node": "22.x" }, "scripts": { "start": "node server.js" }, "dependencies": { "express": "^5.1.0" } } ``` And `server.js`: ```js import express from 'express' import crypto from 'node:crypto' // Fail loudly at boot if a secret is missing — a bot that starts without its // token only fails later, on the first message, with a confusing 401 from Meta. for (const name of [ 'WHATSAPP_VERIFY_TOKEN', 'WHATSAPP_APP_SECRET', 'WHATSAPP_TOKEN', 'WHATSAPP_PHONE_NUMBER_ID' ]) { if (!process.env[name]) throw new Error(`Missing env var ${name}`) } const { PORT, WHATSAPP_VERIFY_TOKEN, WHATSAPP_APP_SECRET, WHATSAPP_TOKEN, WHATSAPP_PHONE_NUMBER_ID } = process.env const app = express() // Keep the raw body around — the signature is computed over the exact bytes // Meta sent, not over the re-serialized JSON. app.use( express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }) ) // Meta calls this once, when you save the webhook URL in the app dashboard. // It expects hub.challenge echoed back as a plain-text 200. app.get('/webhook', (req, res) => { const mode = req.query['hub.mode'] const token = req.query['hub.verify_token'] if (mode === 'subscribe' && token === WHATSAPP_VERIFY_TOKEN) { return res.status(200).send(req.query['hub.challenge']) } res.sendStatus(403) }) // Every inbound message — and every delivery/read status — arrives here. app.post('/webhook', async (req, res) => { if (!isFromMeta(req)) return res.sendStatus(401) // Acknowledge first. Meta retries anything that doesn't get a 200, with // decreasing frequency for up to 7 days, so a slow reply turns one message // into several. res.sendStatus(200) const message = req.body?.entry?.[0]?.changes?.[0]?.value?.messages?.[0] if (message?.type === 'text') { await reply(message.from, `You said: ${message.text.body}`) } }) app.get('/healthz', (_req, res) => res.json({ ok: true })) function isFromMeta(req) { const received = Buffer.from(req.get('x-hub-signature-256') ?? '') const expected = Buffer.from( 'sha256=' + crypto .createHmac('sha256', WHATSAPP_APP_SECRET) .update(req.rawBody) .digest('hex') ) return ( received.length === expected.length && crypto.timingSafeEqual(received, expected) ) } async function reply(to, body) { const res = await fetch( `https://graph.facebook.com/v26.0/${WHATSAPP_PHONE_NUMBER_ID}/messages`, { method: 'POST', headers: { authorization: `Bearer ${WHATSAPP_TOKEN}`, 'content-type': 'application/json' }, body: JSON.stringify({ messaging_product: 'whatsapp', to, type: 'text', text: { body } }) } ) if (!res.ok) console.error('send failed', res.status, await res.text()) } app.listen(PORT, '0.0.0.0', () => console.log(`listening on ${PORT}`)) ``` ## Python `requirements.txt`: ```txt Flask>=3.0 requests>=2.32 ``` And `app.py` — the builder finds `app = Flask(...)`, installs `gunicorn` if it's not in your dependencies, and starts it with `gunicorn app:app --bind 0.0.0.0:$PORT`: ```python import hashlib import hmac import os import requests from flask import Flask, request app = Flask(__name__) # os.environ[...] raises KeyError at import time if a secret is missing, so a # misconfigured deploy fails at boot with the variable name in the logs. VERIFY_TOKEN = os.environ["WHATSAPP_VERIFY_TOKEN"] APP_SECRET = os.environ["WHATSAPP_APP_SECRET"] TOKEN = os.environ["WHATSAPP_TOKEN"] PHONE_NUMBER_ID = os.environ["WHATSAPP_PHONE_NUMBER_ID"] GRAPH_URL = f"https://graph.facebook.com/v26.0/{PHONE_NUMBER_ID}/messages" @app.get("/webhook") def verify(): """Meta calls this once, when you save the webhook URL.""" if ( request.args.get("hub.mode") == "subscribe" and request.args.get("hub.verify_token") == VERIFY_TOKEN ): return request.args.get("hub.challenge", ""), 200 return "", 403 @app.post("/webhook") def receive(): if not is_from_meta(request): return "", 401 payload = request.get_json(silent=True) or {} try: message = payload["entry"][0]["changes"][0]["value"]["messages"][0] except (KeyError, IndexError): # Status callbacks (sent, delivered, read) land here too — nothing to answer. return "", 200 if message.get("type") == "text": send_text(message["from"], f"You said: {message['text']['body']}") return "", 200 @app.get("/healthz") def healthz(): return {"ok": True} def is_from_meta(req) -> bool: # request.get_data() is the raw body — do not rebuild it from request.json. expected = "sha256=" + hmac.new( APP_SECRET.encode(), req.get_data(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, req.headers.get("X-Hub-Signature-256", "")) def send_text(to: str, body: str) -> None: r = requests.post( GRAPH_URL, headers={"Authorization": f"Bearer {TOKEN}"}, json={ "messaging_product": "whatsapp", "to": to, "type": "text", "text": {"body": body}, }, timeout=10, ) if not r.ok: app.logger.error("send failed %s %s", r.status_code, r.text) ``` Meta retries any delivery that doesn't get a `200`, and the retries produce **duplicate notifications**. If answering takes longer than a couple of seconds — an LLM call, an external API — hand the work to a thread or a queue and return `200` immediately. If a duplicate would do harm (charging twice, sending the same reply twice), remember the `message.id` you've already handled in a database; an in-memory set is lost on sleep and on every deploy. ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your bot's repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop: ```bash npm i -g @faable/faable faable login faable deploy ``` ## Set your secrets Never commit tokens. Set them once, and they are available as environment variables on the next deploy: ```bash faable deploy secrets set \ WHATSAPP_VERIFY_TOKEN=a-string-you-invent \ WHATSAPP_APP_SECRET=… \ WHATSAPP_TOKEN=… \ WHATSAPP_PHONE_NUMBER_ID=… ``` Where each one comes from: - **`WHATSAPP_VERIFY_TOKEN`** — any string you make up; you type the same one into Meta's dashboard. - **`WHATSAPP_APP_SECRET`** — your Meta app's **App secret**, under App settings → Basic. It signs the webhooks. - **`WHATSAPP_PHONE_NUMBER_ID`** — under WhatsApp → API setup. - **`WHATSAPP_TOKEN`** — the access token the bot sends _with_. The token shown under WhatsApp → API setup is **temporary and expires after 24 hours**; it's fine for the first test and wrong for production. Create a **System User** in Meta Business settings, assign it the WhatsApp app with the `whatsapp_business_messaging` permission, and generate a token with no expiry. Set that one. See [Environment & Releases](../environment.mdx) for the variables Faable injects for you, and the [CLI reference](../../cli.md#secrets) for loading them from a `.env` file. ## Point Meta at your app In your Meta app, under **WhatsApp → Configuration → Webhook**: - **Callback URL**: `https://.faable.link/webhook` - **Verify token**: the value you set as `WHATSAPP_VERIFY_TOKEN` Save. Meta immediately sends the `GET /webhook` verification request; the handler above answers it. Then subscribe to the **messages** field, and your bot is live — send it a message from the test number and watch the reply come back. A [custom domain](../domains/custom-domain.md) works the same way, with its certificate issued automatically — just use it in the callback URL instead. ## What sleeping does and doesn't break After 30 minutes with no requests on the Free plan — 2 hours on Hobby and Pro — your bot scales to zero. The next WhatsApp message wakes it. What you should know: - **The first message after a sleep is slower** — the container has to start, a cold start of a few seconds. Meta's retries absorb this, but keep boot work light: connect to databases lazily, not at import time. - **In-memory state is gone.** Conversation context held in a module-level dictionary disappears on sleep and on every deploy. Put it in a database — see [Databases](databases.md). - **Scheduled work does not run while asleep.** A bot that also needs to send reminders on a timer needs an external trigger hitting an endpoint, not an in-process `setInterval`. ## Troubleshooting - **Meta says "The callback URL or verify token couldn't be validated"** — the `GET /webhook` route isn't returning `hub.challenge` as a plain body with a `200`, or `WHATSAPP_VERIFY_TOKEN` doesn't match what you typed into Meta. Check the deployment logs (`faable deploy logs`); the verification request shows up there. - **Every message arrives two or three times** — you're doing the work before answering. Send the `200` first, then process, and de-duplicate on `message.id` if it matters. - **Signature check always fails** — the body was parsed and re-serialized before hashing, or you're hashing with the access token instead of the **App secret**. Hash the raw bytes with the App secret, as both examples do. - **The bot worked for a day and then every reply fails with `401`** — you're still on the temporary token from API setup. Generate a permanent System User token (see [Set your secrets](#set-your-secrets)). - **Requests time out** — the server binds a hardcoded port instead of `$PORT`, or listens on `127.0.0.1` instead of `0.0.0.0`. - **The bot stops responding after a few hours** — you're using a pairing library that keeps a socket open. See [Webhooks, not long-polling](#webhooks-not-long-polling). - **The app exits right at boot** — a missing secret. The Python example raises `KeyError` at import, and the Node example checks every variable before starting; the logs show which one is missing. ## FAQ ### Can I host a WhatsApp bot on Faable Deploy? Yes. A WhatsApp Cloud API bot is an HTTPS webhook, and Faable Deploy gives every app a public HTTPS URL with a valid certificate on the first deploy. Push a Node.js or Python repo with a web server in it — no Dockerfile, no YAML, no reverse proxy to configure. ### Do I need a Dockerfile to deploy a WhatsApp bot? No. Faable detects Node.js from `package.json` and Python from `requirements.txt`, installs your dependencies, and starts the app. A Dockerfile is the escape hatch for stacks the buildpacks don't detect natively, and it requires a paid plan. ### Does a WhatsApp bot keep running when nobody is messaging it? It scales to zero after 30 minutes of no traffic on the Free plan — 2 hours on Hobby and Pro — and wakes on the next inbound message, so a webhook bot behaves exactly as you'd expect while costing nothing while idle. On Hobby and Pro, a bot people message regularly never stops. A bot that keeps a WebSocket open instead — Baileys, `whatsapp-web.js` — receives no inbound requests at all, so nothing keeps it awake on any plan, which is why the Cloud API is the right fit here. ### Can I use Baileys or whatsapp-web.js on Faable Deploy? They are a poor fit. Both keep a long-lived socket open and store their pairing session on disk, and Faable's filesystem is ephemeral — the session is lost on every deploy and the socket dies when the app sleeps. Use the official WhatsApp Cloud API webhook instead. ### How do I verify that a webhook really comes from WhatsApp? Compute HMAC-SHA256 of the **raw request body** with your Meta App secret, prefix it with `sha256=`, and compare it in constant time to the `X-Hub-Signature-256` header. Hashing a re-serialised JSON object, or using the access token as the key, fails every time. ### Why does my WhatsApp bot stop replying after 24 hours? The access token shown in Meta's API setup page is temporary and expires after 24 hours. Create a System User in Business settings and generate a permanent token for production. ### How do I avoid answering the same WhatsApp message twice? Return `200` before doing any work — Meta retries deliveries it doesn't get a `200` for, and the retries are duplicates. If duplicates would still hurt, store the `message.id` of each notification you've processed in a database and skip repeats. ### How do I keep my WhatsApp access token out of my repo? Set it as a secret with `faable deploy secrets set WHATSAPP_TOKEN=…`, or from the dashboard, and read it from the environment. Secrets are injected at runtime and never live in your git history or the build image. ### Which port should my WhatsApp bot listen on? Read the `PORT` environment variable and bind `0.0.0.0`. Faable sets it for you and routes public HTTPS traffic to it. ## Related - [Deploy a Telegram Bot](guide-telegram-bot.md) — the same shape, with Telegram's webhook API - [Deploy a Discord Bot](guide-discord-bot.md) — the same shape, over Discord's HTTP interactions - [Deploy a Slack App](guide-slack-bot.md) — the same shape, over Slack's Request URL - [Deploy a Stripe Webhook Endpoint](guide-stripe-webhooks.md) — webhooks with idempotency - [Deploy an LLM streaming app](guide-llm-streaming.md) — if the bot's replies come from a model - [Deploy Flask](guide-flask.md) · [Deploy Node.js Express](guide-express.md) · [Deploy FastAPI](guide-fastapi.md) - [Databases & SQLite](databases.md) — where to keep conversation state and processed message ids - [Environment & Releases](../environment.mdx) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [What the Builder Expects](../build-requirements.mdx) — detection rules and the `$PORT` contract - [Add authentication to your app](../../auth/get-started.md) — Faable Auth is included in the same subscription --- ## Deploy a Discord Bot Source: https://faable.com/docs/deploy/guides/guide-discord-bot # Deploy a Discord Bot 🎮 **Run your Discord bot on HTTP interactions and Faable Deploy is all the infrastructure it needs.** Push a repo with a small web server in it and Faable builds it, gives it a public HTTPS URL with a valid certificate at `https://.faable.link`, and puts a [WAF](../security-waf.md) in front of it. Discord requires a public HTTPS endpoint with a trusted certificate to deliver interactions; you have one from the first deploy. This guide covers **Node.js** and **Python** with the plain HTTP API — no framework required. ## HTTP interactions, not the gateway Make this decision first, because it decides whether your bot works at all — and it is the one most Discord tutorials get wrong for a platform like this one. Discord offers your bot two completely different ways to hear from users, and they are not interchangeable: - **HTTP interactions.** You give Discord a URL, and it `POST`s an interaction to it every time somebody runs one of your slash commands. Traffic is **inbound**, so the request itself wakes your app. - **The gateway.** Your bot opens a persistent WebSocket _out_ to Discord and keeps it alive. This is what `discord.js` and `discord.py` do by default, and what almost every tutorial shows. Faable Deploy scales an app to zero once no HTTP request has arrived for a while — **30 minutes on the Free plan, 2 hours on Hobby and Pro** — and wakes it on the next one. HTTP interactions fit that exactly: somebody runs a command, the request wakes your app, your app answers. Idle time is free, and a bot people actually use never sleeps at all. A gateway bot does the opposite. Its connection is outbound, so **no inbound request ever arrives** — nothing keeps it awake, and it sleeps as soon as that window elapses **on any plan**, dropping the socket. | | HTTP interactions | Gateway (WebSocket) | | :--------------------- | :------------------------------------- | :-------------------------------------------------------------- | | How events arrive | Inbound HTTPS POST from Discord | Outbound socket your app holds open | | Survives scale-to-zero | Yes — the interaction wakes the app | No — nothing keeps the app awake | | Cost while idle | Nothing | Constantly running | | Covers | Slash commands, buttons, menus, modals | Everything, plus message content, presence, reactions and voice | **Slash commands, buttons, select menus and modals all work over HTTP interactions**, which is the whole surface most bots need. If your bot genuinely has to read every message in a channel, track presence or join voice, it needs the gateway — and it needs a host that keeps a process alive with no inbound traffic, which is not what this platform does today. ## What Faable detects Detection is file-based ([full rules](../build-requirements.mdx)): - **Node.js** — a `package.json` with a `start` script. Supported versions: 20, 22 and 24. - **Python** — a `requirements.txt`, `pyproject.toml` or `Pipfile`, plus a module defining your app object. Supported versions: 3.10, 3.11, 3.12 and 3.13. ## Serve on `$PORT` Faable assigns your app a port and passes it as the `PORT` environment variable. Bind `0.0.0.0` and read it — a hardcoded port means Discord's deliveries time out and it disables your endpoint. ## Verify the signature, on the raw body This is the part that trips people up, so it is worth being precise before the code. Every interaction carries `X-Signature-Ed25519` and `X-Signature-Timestamp`. You verify the signature over **`timestamp` concatenated with the exact request body bytes**, using your app's **public key** from the Developer Portal. Two consequences: - **You need the raw body, not the parsed JSON.** Re-serialising the parsed object produces different bytes and every signature fails. Capture the buffer before parsing. - **An invalid signature must get a `401`.** This is not just good hygiene: when you save an Interactions Endpoint URL, Discord deliberately sends requests with **bad** signatures and refuses the URL if you answer them with anything but `401`. Discord also sends a `PING` (`type: 1`) which you answer with a `PONG` (`type: 1`). Until that works, the URL cannot be saved at all. ## Node.js `package.json`: ```json { "name": "discord-bot", "type": "module", "scripts": { "start": "node server.js" }, "dependencies": { "express": "^4.19.2", "tweetnacl": "^1.0.3" } } ``` `server.js`: ```js import express from 'express' import nacl from 'tweetnacl' const { PORT, DISCORD_PUBLIC_KEY } = process.env const app = express() // Keep the raw bytes: the signature is over the body exactly as it arrived. app.use( express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }) ) function isFromDiscord(req) { const signature = req.get('x-signature-ed25519') const timestamp = req.get('x-signature-timestamp') if (!signature || !timestamp) return false return nacl.sign.detached.verify( Buffer.concat([Buffer.from(timestamp), req.rawBody]), Buffer.from(signature, 'hex'), Buffer.from(DISCORD_PUBLIC_KEY, 'hex') ) } app.post('/discord/interactions', (req, res) => { // Discord validates your endpoint by sending deliberately bad signatures. // Anything other than 401 here and it refuses to save the URL. if (!isFromDiscord(req)) return res.status(401).send('invalid request signature') const interaction = req.body // 1 = PING. Discord sends this to check the endpoint is alive. if (interaction.type === 1) return res.json({ type: 1 }) // 2 = APPLICATION_COMMAND (a slash command). if (interaction.type === 2) { const name = interaction.data.name // 4 = CHANNEL_MESSAGE_WITH_SOURCE — reply immediately. return res.json({ type: 4, data: { content: `You ran /${name}` } }) } res.sendStatus(400) }) app.get('/healthz', (_req, res) => res.json({ ok: true })) app.listen(PORT, '0.0.0.0', () => console.log(`listening on ${PORT}`)) ``` ## Python `requirements.txt`: ```txt Flask>=3.0 PyNaCl>=1.5 requests>=2.32 gunicorn ``` `app.py` — the builder finds `app = Flask(...)` and starts it with `gunicorn app:app --bind 0.0.0.0:$PORT`: ```python import os from flask import Flask, jsonify, request from nacl.exceptions import BadSignatureError from nacl.signing import VerifyKey app = Flask(__name__) verify_key = VerifyKey(bytes.fromhex(os.environ["DISCORD_PUBLIC_KEY"])) def is_from_discord() -> bool: signature = request.headers.get("X-Signature-Ed25519") timestamp = request.headers.get("X-Signature-Timestamp") if not signature or not timestamp: return False try: # request.data is the raw body — do not rebuild it from the parsed JSON. verify_key.verify(timestamp.encode() + request.data, bytes.fromhex(signature)) except (BadSignatureError, ValueError): return False return True @app.post("/discord/interactions") def interactions(): # Discord probes with bad signatures on setup; it expects a 401. if not is_from_discord(): return "invalid request signature", 401 interaction = request.get_json(silent=True) or {} # 1 = PING if interaction.get("type") == 1: return jsonify(type=1) # 2 = APPLICATION_COMMAND if interaction.get("type") == 2: name = interaction["data"]["name"] # 4 = CHANNEL_MESSAGE_WITH_SOURCE return jsonify(type=4, data={"content": f"You ran /{name}"}) return "", 400 @app.get("/healthz") def healthz(): return {"ok": True} ``` ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your bot's repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop: ```bash npm i -g @faable/faable faable login faable deploy ``` ## Set your secrets Never commit your bot token — anyone holding it controls your bot: ```bash faable deploy secrets set \ DISCORD_PUBLIC_KEY= \ DISCORD_APP_ID= \ DISCORD_BOT_TOKEN= ``` All three come from the [Discord Developer Portal](https://discord.com/developers/applications): the **public key** and **application id** from _General Information_, the **bot token** from _Bot_. The public key is what verifies incoming interactions; the bot token is what authenticates you when you call Discord's API. See [Environment & Releases](../environment.mdx) for the variables Faable injects for you. ## Register your commands Discord only sends interactions for commands it knows about, so register them once. This is a one-off `curl`: ```bash # Guild-scoped: appears instantly. Use this while developing. curl -X PUT \ "https://discord.com/api/v10/applications/$DISCORD_APP_ID/guilds/$GUILD_ID/commands" \ -H "Authorization: Bot $DISCORD_BOT_TOKEN" \ -H 'content-type: application/json' \ -d '[{"name":"ping","description":"Check the bot is alive","type":1}]' ``` Drop `/guilds/$GUILD_ID` for a global command, available in every server your app is in — those can take up to an hour to propagate, which is why guild commands are the sane choice while you iterate. ## Point Discord at your app In the Developer Portal, under _General Information_, set **Interactions Endpoint URL** to your route: ``` https://.faable.link/discord/interactions ``` Saving it triggers the validation described above: a `PING` that must get a `PONG`, and bogus-signature requests that must get `401`. If it saves, you are live. A [custom domain](../domains/custom-domain.md) works identically — use that URL instead. Invite the app to a server with the `applications.commands` scope (add `bot` too if it also needs a bot user), from _OAuth2 → URL Generator_. ## The 3-second rule, and how cold starts fit in it **Discord expects your first response within 3 seconds** or it shows the user "The application did not respond" and discards the interaction. That is a hard deadline, and it is the one thing worth designing around on a platform that scales to zero. The tool for it is a **deferred response**: answer `type: 5` immediately, which makes Discord show a "thinking…" state, and then edit the real reply in within the next 15 minutes. ```js // Answer instantly, then take your time. res.json({ type: 5 }) // DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE const url = `https://discord.com/api/v10/webhooks/${process.env.DISCORD_APP_ID}/${interaction.token}/messages/@original` await fetch(url, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: await somethingSlow() }) }) ``` Defer anything that calls another API, queries a database or asks an LLM. It costs you one extra line and removes the deadline from the equation entirely — including on the first request after a sleep, when the container is still starting. ## What sleeping does and doesn't break After 30 minutes with no requests on the Free plan — 2 hours on Hobby and Pro — your bot scales to zero. The next interaction wakes it. What that means in practice: - **The first interaction after a sleep is slower** — the container starts up. This is precisely what deferred replies absorb; keep boot work light too, and open database connections lazily rather than at import time. - **In-memory state is gone.** Anything held in a module-level dictionary disappears on sleep and on every deploy. Persist it — see [Databases](databases.md). - **Timers don't fire while asleep.** A bot that posts a daily announcement needs an external trigger hitting an endpoint, not an in-process `setInterval`. ## Troubleshooting - **"Interactions Endpoint URL could not be verified"** — the three usual causes, in order: you answered a bad-signature request with something other than `401`; you verified against the parsed JSON instead of the raw body; or you used the bot token where the public key belongs. - **Every signature fails, and the code looks right** — a body parser ran before you captured the buffer. `express.json()` without the `verify` hook leaves you nothing to check against. - **"The application did not respond"** — you took longer than 3 seconds. Defer with `type: 5`. - **Commands don't appear in Discord** — they were never registered, or you registered them globally and are still inside the propagation window. Register guild-scoped while developing. - **Requests time out** — the server binds a hardcoded port instead of `$PORT`, or listens on `127.0.0.1` instead of `0.0.0.0`. - **The app exits right at boot** — a missing secret. `os.environ["…"]` throws at import time; the logs name it. ## FAQ ### Can I host a Discord bot on Faable Deploy? Yes. Build it on HTTP interactions and it is an ordinary web server. Faable Deploy gives every app a public HTTPS URL with a valid certificate on the first deploy, which is exactly what Discord's Interactions Endpoint URL requires. Push a Node.js or Python repo — no Dockerfile, no YAML. ### Should my Discord bot use HTTP interactions or the gateway on Faable? HTTP interactions. Faable scales an app to zero after 30 minutes without an inbound request on the Free plan, or 2 hours on Hobby and Pro. A gateway bot holds an outbound WebSocket and receives no inbound traffic at all, so nothing keeps it awake and it sleeps on every plan. An interactions bot is woken by each command, costs nothing while idle, and stays up continuously once it has real traffic. ### Can I use discord.js or discord.py on Faable Deploy? Only in their HTTP-interactions mode. Their default entry points — `client.login()` and `bot.run()` — open a gateway connection, which is the pattern that does not survive scale-to-zero. Slash commands, buttons, select menus and modals are all available over HTTP; message content, presence, reactions and voice are not. ### How do I verify that an interaction really came from Discord? Verify the `X-Signature-Ed25519` header against `X-Signature-Timestamp` plus the raw request body, using your application's public key. Reject anything that fails with a `401` — Discord tests exactly that when you save your endpoint URL. ### Why does my Discord bot say "The application did not respond"? Discord requires a response within 3 seconds. If your handler does anything slow, reply with a deferred response (`type: 5`) straight away and edit the message afterwards through the interaction webhook. ### Which port should my Discord bot listen on? Read the `PORT` environment variable and bind `0.0.0.0`. Faable sets it and routes public HTTPS traffic to it. ## Related - [Deploy a Slack App](guide-slack-bot.md) · [Deploy a Telegram Bot](guide-telegram-bot.md) · [Deploy a WhatsApp Bot](guide-whatsapp-bot.md) — the same shape, on other platforms - [Deploy a Stripe Webhook Endpoint](guide-stripe-webhooks.md) — the same verify-and-acknowledge shape, plus idempotency - [Deploy an LLM App with Streaming](guide-llm-streaming.md) — put a model behind the bot - [Deploy Flask](guide-flask.md) · [Deploy Node.js Express](guide-express.md) · [Deploy FastAPI](guide-fastapi.md) - [Databases & SQLite](databases.md) — where to keep state between interactions - [Environment & Releases](../environment.mdx) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [What the Builder Expects](../build-requirements.mdx) — detection rules and the `$PORT` contract - [Add authentication to your app](../../auth/get-started.md) — Faable Auth is included in the same subscription --- ## Deploy a Slack App Source: https://faable.com/docs/deploy/guides/guide-slack-bot # Deploy a Slack App 💼 **A Slack app that listens over HTTP is an ordinary web server — and that is exactly what Faable Deploy runs best.** Push a repo with a small server in it and Faable builds it, gives it a public HTTPS URL with a valid certificate at `https://.faable.link`, and puts a [WAF](../security-waf.md) in front of it. Slack requires a public HTTPS Request URL with a trusted certificate; you have one from the first deploy. This guide covers **Node.js** and **Python** with the plain Slack HTTP API — no framework required. ## HTTP Request URL, not Socket Mode Make this decision first, because it decides whether your app works at all. Slack gives you two ways to receive events, and they are not interchangeable: - **A Request URL.** Slack `POST`s every event, slash command and button click to a URL you own. Traffic is **inbound**, so the request itself wakes your app. - **Socket Mode.** Your app opens a persistent WebSocket _out_ to Slack and holds it open. It exists so you can develop behind a firewall without a public URL. Faable Deploy scales an app to zero once no HTTP request has arrived for a while — **30 minutes on the Free plan, 2 hours on Hobby and Pro** — and wakes it on the next one. A Request URL fits that exactly: somebody runs your command, the request wakes your app, your app answers. Idle time is free, and an app your team actually uses never sleeps at all. Socket Mode does the opposite. The connection is outbound, so **no inbound request ever arrives** — nothing keeps the app awake, and it sleeps as soon as that window elapses **on any plan**, dropping the socket. | | Request URL (HTTP) | Socket Mode (WebSocket) | | :--------------------- | :------------------------------------ | :-------------------------------------- | | How events arrive | Inbound HTTPS POST from Slack | Outbound socket your app holds open | | Survives scale-to-zero | Yes — the event wakes the app | No — nothing keeps the app awake | | Cost while idle | Nothing | Constantly running | | Needs a public URL | Yes — you get one on the first deploy | No — which is the only reason it exists | If you use **Bolt**, this is the `socketMode: true` flag (JS) or `SocketModeHandler` (Python). Leave it off and let Bolt run its HTTP receiver. ## What Faable detects Detection is file-based ([full rules](../build-requirements.mdx)): - **Node.js** — a `package.json` with a `start` script. Supported versions: 20, 22 and 24. - **Python** — a `requirements.txt`, `pyproject.toml` or `Pipfile`, plus a module defining your app object. Supported versions: 3.10, 3.11, 3.12 and 3.13. ## Serve on `$PORT` Faable assigns your app a port and passes it as the `PORT` environment variable. Bind `0.0.0.0` and read it — a hardcoded port means Slack's deliveries time out and it disables your Request URL. ## Verify the signature, on the raw body Slack signs every request with your **Signing Secret**. The rules that matter: - The signature base string is **`v0:{timestamp}:{raw body}`**, HMAC-SHA256 with the signing secret, compared against the `X-Slack-Signature` header (which is prefixed `v0=`). - **You need the raw body.** Slash commands arrive `application/x-www-form-urlencoded` and events arrive as JSON; in both cases the signature is over the exact bytes. Re-serialising a parsed object breaks it. - **Reject anything older than five minutes**, using `X-Slack-Request-Timestamp`. That is Slack's own guidance and it is what stops a captured request being replayed. - Compare in constant time. ## Node.js `package.json`: ```json { "name": "slack-app", "type": "module", "scripts": { "start": "node server.js" }, "dependencies": { "express": "^4.19.2" } } ``` `server.js`: ```js import express from 'express' import crypto from 'node:crypto' const { PORT, SLACK_SIGNING_SECRET, SLACK_BOT_TOKEN } = process.env const app = express() // Keep the raw bytes for both content types Slack uses — the signature is over // exactly what arrived, not over the parsed object. const keepRaw = (req, _res, buf) => { req.rawBody = buf } app.use(express.json({ verify: keepRaw })) app.use(express.urlencoded({ extended: true, verify: keepRaw })) function isFromSlack(req) { const signature = req.get('x-slack-signature') const timestamp = req.get('x-slack-request-timestamp') if (!signature || !timestamp) return false // Replay protection: anything older than five minutes is refused. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false const expected = 'v0=' + crypto .createHmac('sha256', SLACK_SIGNING_SECRET) .update(`v0:${timestamp}:${req.rawBody}`) .digest('hex') const a = Buffer.from(signature) const b = Buffer.from(expected) return a.length === b.length && crypto.timingSafeEqual(a, b) } // Events API — messages, mentions, reactions. app.post('/slack/events', async (req, res) => { if (!isFromSlack(req)) return res.sendStatus(401) // Slack proves you own the URL by asking you to echo a challenge back. if (req.body.type === 'url_verification') { return res.json({ challenge: req.body.challenge }) } // Acknowledge first. Slack retries anything it doesn't get a 200 for within // three seconds, so slow work here turns one event into duplicates. res.sendStatus(200) if (req.get('x-slack-retry-num')) return // already handled, don't repeat it const event = req.body.event if (event?.type === 'app_mention') { await postMessage(event.channel, `You said: ${event.text}`) } }) // Slash commands — form-encoded, and they expect a visible answer. app.post('/slack/commands', (req, res) => { if (!isFromSlack(req)) return res.sendStatus(401) res.json({ response_type: 'ephemeral', // 'in_channel' to show it to everybody text: `You ran ${req.body.command} ${req.body.text}` }) }) app.get('/healthz', (_req, res) => res.json({ ok: true })) app.listen(PORT, '0.0.0.0', () => console.log(`listening on ${PORT}`)) async function postMessage(channel, text) { await fetch('https://slack.com/api/chat.postMessage', { method: 'POST', headers: { authorization: `Bearer ${SLACK_BOT_TOKEN}`, 'content-type': 'application/json' }, body: JSON.stringify({ channel, text }) }) } ``` ## Python `requirements.txt`: ```txt Flask>=3.0 requests>=2.32 gunicorn ``` `app.py` — the builder finds `app = Flask(...)` and starts it with `gunicorn app:app --bind 0.0.0.0:$PORT`: ```python import hashlib import hmac import os import time import requests from flask import Flask, jsonify, request app = Flask(__name__) SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"] BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"] def is_from_slack() -> bool: signature = request.headers.get("X-Slack-Signature", "") timestamp = request.headers.get("X-Slack-Request-Timestamp", "") if not signature or not timestamp: return False # Replay protection: anything older than five minutes is refused. try: if abs(time.time() - int(timestamp)) > 300: return False except ValueError: return False # request.get_data() is the raw body — do not rebuild it from the parsed form. basestring = b"v0:" + timestamp.encode() + b":" + request.get_data() expected = "v0=" + hmac.new( SIGNING_SECRET.encode(), basestring, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature) @app.post("/slack/events") def events(): if not is_from_slack(): return "", 401 payload = request.get_json(silent=True) or {} # Slack proves you own the URL by asking you to echo a challenge back. if payload.get("type") == "url_verification": return jsonify(challenge=payload["challenge"]) # Retries arrive with this header; ignoring them avoids double-posting. if request.headers.get("X-Slack-Retry-Num"): return "", 200 event = payload.get("event") or {} if event.get("type") == "app_mention": post_message(event["channel"], f"You said: {event['text']}") return "", 200 @app.post("/slack/commands") def commands(): if not is_from_slack(): return "", 401 return jsonify( response_type="ephemeral", # "in_channel" to show it to everybody text=f"You ran {request.form['command']} {request.form.get('text', '')}", ) @app.get("/healthz") def healthz(): return {"ok": True} def post_message(channel: str, text: str) -> None: requests.post( "https://slack.com/api/chat.postMessage", headers={"Authorization": f"Bearer {BOT_TOKEN}"}, json={"channel": channel, "text": text}, timeout=10, ) ``` ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your app's repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop: ```bash npm i -g @faable/faable faable login faable deploy ``` ## Set your secrets Never commit your tokens — the bot token can post as your app in every workspace that installed it: ```bash faable deploy secrets set \ SLACK_SIGNING_SECRET= \ SLACK_BOT_TOKEN=xoxb-… ``` Both come from [api.slack.com/apps](https://api.slack.com/apps): the **signing secret** from _Basic Information → App Credentials_, the **bot token** from _OAuth & Permissions_ after you install the app to a workspace. See [Environment & Releases](../environment.mdx) for the variables Faable injects for you. ## Point Slack at your app Once the app is live, in your Slack app configuration: - **Event Subscriptions** → enable, and set the Request URL to `https://.faable.link/slack/events`. Slack immediately POSTs a `url_verification` challenge; the handler above answers it and the URL turns verified. Then subscribe to the bot events you need — `app_mention`, `message.channels`, and so on. - **Slash Commands** → create your command with the Request URL `https://.faable.link/slack/commands`. - **Interactivity & Shortcuts** → if you use buttons or modals, point it at a route of your own. Those arrive form-encoded with a single `payload` field holding JSON. A [custom domain](../domains/custom-domain.md) works identically — use that URL instead. Changing your app's URL later means editing it in all three places. ## The 3-second rule, retries, and cold starts **Slack expects a `200` within 3 seconds.** Miss it and two things happen: a slash command shows the user an operation-timeout error, and an event is **retried up to three times** — which is how one mention becomes three replies. So the shape is always the same: **acknowledge immediately, work afterwards.** - **Events:** answer `200` before doing anything slow, and skip requests carrying `X-Slack-Retry-Num` so a retry you already handled doesn't post twice. - **Slash commands:** answer `200` straight away, then send the real reply to the `response_url` in the payload — it is valid for **30 minutes** and accepts up to five messages. ```js // Acknowledge now, answer properly later. res.json({ response_type: 'ephemeral', text: 'Working on it…' }) await fetch(req.body.response_url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ response_type: 'in_channel', text: await somethingSlow() }) }) ``` That pattern also absorbs the first request after a sleep, when the container is still starting. ## What sleeping does and doesn't break After 30 minutes with no requests on the Free plan — 2 hours on Hobby and Pro — your app scales to zero. The next Slack event wakes it. What that means in practice: - **The first event after a sleep is slower** — the container starts up. Acknowledging immediately, as above, is what keeps that inside Slack's window; keep boot work light too, and open database connections lazily rather than at import time. - **In-memory state is gone.** Anything held in a module-level dictionary disappears on sleep and on every deploy. Persist it — see [Databases](databases.md). - **Timers don't fire while asleep.** A standup reminder needs an external trigger hitting an endpoint, not an in-process `setInterval`. ## Troubleshooting - **"Your URL didn't respond with the value of the challenge parameter"** — the `url_verification` branch never ran. Usually the signature check rejected it first, or a body parser consumed the request before you captured the raw bytes. - **Every signature fails and the code looks right** — you are hashing the parsed body. `express.json()` and `express.urlencoded()` both need the `verify` hook; in Flask use `request.get_data()`, not `request.form`. - **The bot replies three times to one message** — you acknowledged after doing the work, so Slack retried. Answer `200` first and ignore `X-Slack-Retry-Num`. - **`/command` shows an operation timeout** — the handler took longer than 3 seconds. Acknowledge, then post to `response_url`. - **`not_in_channel` or `missing_scope` from `chat.postMessage`** — an installation problem, not a deploy one: invite the bot to the channel, or add the scope and reinstall. - **Requests time out** — the server binds a hardcoded port instead of `$PORT`, or listens on `127.0.0.1` instead of `0.0.0.0`. - **The app exits right at boot** — a missing secret. `os.environ["…"]` throws at import time; the logs name it. ## FAQ ### Can I host a Slack bot on Faable Deploy? Yes. Run it on a Request URL rather than Socket Mode and it is an ordinary web server. Faable Deploy gives every app a public HTTPS URL with a valid certificate on the first deploy, which is exactly what Slack's Event Subscriptions require. Push a Node.js or Python repo — no Dockerfile, no YAML. ### Should my Slack app use a Request URL or Socket Mode on Faable? A Request URL. Faable scales an app to zero after 30 minutes without an inbound request on the Free plan, or 2 hours on Hobby and Pro. Socket Mode holds an outbound WebSocket and receives no inbound traffic at all, so nothing keeps it awake and it sleeps on every plan. With a Request URL each event wakes the app, it costs nothing while idle, and it stays up continuously once it has real traffic. ### Can I use Bolt on Faable Deploy? Yes, in its HTTP mode — which is the default. Leave `socketMode` off (JS) and use the standard app runner rather than `SocketModeHandler` (Python), then serve on the injected `PORT`. Bolt verifies the signing secret for you. ### How do I verify that a request really came from Slack? HMAC-SHA256 the string `v0:{timestamp}:{raw body}` with your signing secret and compare it in constant time to the `X-Slack-Signature` header. Reject anything whose `X-Slack-Request-Timestamp` is more than five minutes old. ### Why does my Slack bot answer the same message several times? You acknowledged too late. Slack retries an event up to three times when it doesn't get a `200` within 3 seconds. Return `200` before doing any slow work, and ignore requests that carry the `X-Slack-Retry-Num` header. ### Which port should my Slack app listen on? Read the `PORT` environment variable and bind `0.0.0.0`. Faable sets it and routes public HTTPS traffic to it. ## Related - [Deploy a Discord Bot](guide-discord-bot.md) · [Deploy a Telegram Bot](guide-telegram-bot.md) · [Deploy a WhatsApp Bot](guide-whatsapp-bot.md) — the same shape, on other platforms - [Deploy a Stripe Webhook Endpoint](guide-stripe-webhooks.md) — the same verify-and-acknowledge shape, plus idempotency - [Deploy an LLM App with Streaming](guide-llm-streaming.md) — put a model behind the bot - [Deploy Flask](guide-flask.md) · [Deploy Node.js Express](guide-express.md) · [Deploy FastAPI](guide-fastapi.md) - [Databases & SQLite](databases.md) — where to keep state between events - [Environment & Releases](../environment.mdx) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [What the Builder Expects](../build-requirements.mdx) — detection rules and the `$PORT` contract - [Add authentication to your app](../../auth/get-started.md) — Faable Auth is included in the same subscription --- ## Deploy a Stripe Webhook Endpoint Source: https://faable.com/docs/deploy/guides/guide-stripe-webhooks # Deploy a Stripe Webhook Endpoint 💳 **A webhook endpoint is a small web server with one job, and that is exactly what Faable Deploy runs best.** Push a repo with it and Faable builds it, gives it a public HTTPS URL with a valid certificate at `https://.faable.link`, and puts a [WAF](../security-waf.md) in front of it. Stripe requires a public HTTPS endpoint with a trusted certificate; you have one from the first deploy. This guide covers **Node.js** and **Python**. Stripe is the worked example, but the shape is the same for GitHub, Shopify, Twilio and Meta — the differences are in the header name and the hashing recipe. ## Scale-to-zero is an advantage here Most guides about hosting warn you that an app which sleeps is a liability. For a webhook endpoint the opposite is true, and it is worth understanding before you worry about it. Faable Deploy scales an app to zero once no HTTP request has arrived for a while — **30 minutes on the Free plan, 2 hours on Hobby and Pro** — and wakes it on the next one. **Stripe retries a failed delivery with exponential backoff for up to three days.** So the worst a cold start can do is delay an event, not lose it. And an endpoint with steady volume never sleeps at all: each event is inbound traffic that keeps it awake. What that costs you is a design constraint you needed anyway: > **Your handler must be idempotent.** Stripe's delivery is _at-least-once_, so duplicates are guaranteed rather than exceptional — and on a platform that sleeps, the first request after a nap is exactly the one most likely to time out and be retried. That is the real subject of this guide. ## What Faable detects Detection is file-based ([full rules](../build-requirements.mdx)): - **Node.js** — a `package.json` with a `start` script. Supported versions: 20, 22 and 24. - **Python** — a `requirements.txt`, `pyproject.toml` or `Pipfile`, plus a module defining your app object. Supported versions: 3.10, 3.11, 3.12 and 3.13. ## Serve on `$PORT` Faable assigns your app a port and passes it as the `PORT` environment variable. Bind `0.0.0.0` and read it — a hardcoded port means Stripe's deliveries time out and count as failures. ## Verify the signature, on the raw body Stripe signs every delivery with the **endpoint's** signing secret — the `whsec_…` shown when you create the endpoint, which is not your API key. - The header is `Stripe-Signature`, carrying a timestamp `t=` and one or more `v1=` signatures. - The signed payload is **`{t}.{raw body}`**, HMAC-SHA256 with the signing secret. - Stripe's own tolerance is **5 minutes** on that timestamp, which is what stops a captured request being replayed. **You need the raw bytes.** This is the single most common way a webhook endpoint breaks: a JSON body parser runs first, and the re-serialised object no longer matches what was signed. In Express that means mounting `express.raw()` on the webhook route — and mounting it _before_ any global `express.json()`. Both official libraries do the verification for you; let them. ## Node.js `package.json`: ```json { "name": "stripe-webhooks", "type": "module", "scripts": { "start": "node server.js" }, "dependencies": { "express": "^4.19.2", "stripe": "^17.0.0" } } ``` `server.js`: ```js import express from 'express' import Stripe from 'stripe' const { PORT, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET } = process.env const stripe = new Stripe(STRIPE_SECRET_KEY) const app = express() // The webhook route needs the raw bytes, so it gets its own parser and it goes // BEFORE any global express.json(). Every other route can parse normally. app.post( '/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => { let event try { event = stripe.webhooks.constructEvent( req.body, // the Buffer, untouched req.get('stripe-signature'), STRIPE_WEBHOOK_SECRET ) } catch (err) { // Bad signature or a timestamp outside Stripe's five-minute tolerance. console.warn('webhook signature failed:', err.message) return res.sendStatus(400) } // Acknowledge before doing any work: a slow answer is a failed delivery, // and a failed delivery comes back as a duplicate. res.sendStatus(200) if (await alreadyHandled(event.id)) return try { await handle(event) await markHandled(event.id) } catch (err) { // Don't record it: let Stripe's retry bring it back. console.error('handler failed for', event.id, err) } } ) app.use(express.json()) app.get('/healthz', (_req, res) => res.json({ ok: true })) app.listen(PORT, '0.0.0.0', () => console.log(`listening on ${PORT}`)) async function handle(event) { switch (event.type) { case 'checkout.session.completed': { // Don't trust the payload as current state — read the object back. const session = await stripe.checkout.sessions.retrieve( event.data.object.id ) await fulfil(session) break } default: // Unhandled types are fine. Return 200 and move on. break } } ``` `alreadyHandled` / `markHandled` are a single row keyed by `event.id` in whatever database you already have — see [Databases](databases.md). It is the whole of your idempotency. ## Python `requirements.txt`: ```txt Flask>=3.0 stripe>=11.0 gunicorn ``` `app.py` — the builder finds `app = Flask(...)` and starts it with `gunicorn app:app --bind 0.0.0.0:$PORT`: ```python import os import stripe from flask import Flask, request app = Flask(__name__) stripe.api_key = os.environ["STRIPE_SECRET_KEY"] WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"] @app.post("/stripe/webhook") def webhook(): try: # request.get_data() is the raw body — never request.get_json() here. event = stripe.Webhook.construct_event( request.get_data(), request.headers.get("Stripe-Signature", ""), WEBHOOK_SECRET, ) except (ValueError, stripe.SignatureVerificationError) as err: app.logger.warning("webhook signature failed: %s", err) return "", 400 if already_handled(event["id"]): return "", 200 handle(event) mark_handled(event["id"]) return "", 200 @app.get("/healthz") def healthz(): return {"ok": True} def handle(event) -> None: if event["type"] == "checkout.session.completed": # Don't trust the payload as current state — read the object back. session = stripe.checkout.Session.retrieve(event["data"]["object"]["id"]) fulfil(session) ``` If your handler does something slow — sending mail, calling another API — move it off the request: acknowledge, then do the work in a background thread or a queue. Anything that keeps Stripe waiting is a delivery that will be retried. ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop: ```bash npm i -g @faable/faable faable login faable deploy ``` ## Set your secrets Never commit either of these — your secret key can move money: ```bash faable deploy secrets set \ STRIPE_SECRET_KEY=sk_live_… \ STRIPE_WEBHOOK_SECRET=whsec_… ``` The **secret key** comes from the Stripe Dashboard under _Developers → API keys_. The **webhook secret** is shown when you create the endpoint, in _Developers → Webhooks_, and it is **per endpoint** — your local `stripe listen` secret and your deployed one are different values. See [Environment & Releases](../environment.mdx) for the variables Faable injects for you. ## Register the endpoint In the Stripe Dashboard, _Developers → Webhooks → Add endpoint_: - **Endpoint URL**: `https://.faable.link/stripe/webhook` - **Events**: select only the ones you handle. Subscribing to everything means paying a cold start and a database round trip for events you ignore. A [custom domain](../domains/custom-domain.md) works identically — register that URL instead. ### Test it before you trust it The [Stripe CLI](https://docs.stripe.com/stripe-cli) replays real events at a local server, so you can get the whole thing right before deploying: ```bash stripe listen --forward-to localhost:3000/stripe/webhook # prints a whsec_… for this session — use it as STRIPE_WEBHOOK_SECRET locally stripe trigger checkout.session.completed ``` Send the same event twice and confirm your handler only acts once. That is the test that matters here. ## The two things that actually bite **Events arrive out of order.** Stripe does not guarantee ordering, so a `customer.subscription.updated` can land before the `created` it followed. Do not rebuild state by replaying events in the order you receive them: when an event tells you something changed, **retrieve the object from the API** and act on what it says now. The examples above do this deliberately. **Sustained failures get your endpoint disabled.** Stripe retries for days, but an endpoint that keeps failing is eventually turned off and you are emailed about it. A `500` because a secret is missing is not a quiet problem — check your app's logs (`faable deploy logs`, or the dashboard) after the first deploy rather than waiting to notice. ## What sleeping does and doesn't break After 30 minutes with no requests on the Free plan — 2 hours on Hobby and Pro — your endpoint scales to zero. The next event wakes it. What that means in practice: - **The first event after a sleep is slower** — the container starts up. Stripe's retries absorb it; keep boot work light and open database connections lazily rather than at import time. - **In-memory state is gone.** An idempotency set held in a module-level variable disappears on sleep and on every deploy, which quietly turns idempotency off. It belongs in a database — see [Databases](databases.md). - **Timers don't fire while asleep.** Reconciling with Stripe on a schedule needs an external trigger hitting an endpoint, not an in-process `setInterval`. ## Troubleshooting - **`No signatures found matching the expected signature for payload`** — you verified against a parsed body. The webhook route needs `express.raw()` (Node) or `request.get_data()` (Python), and in Express it must be mounted before any global `express.json()`. - **Signature fails only in production** — you deployed the `whsec_` from `stripe listen`. Each endpoint has its own secret. - **`Timestamp outside the tolerance zone`** — the request is older than five minutes. Usually a replayed capture, occasionally a badly skewed clock. - **The same order is fulfilled twice** — no idempotency, or idempotency kept in memory. Persist `event.id`. - **The Stripe dashboard shows failed deliveries with no logs on your side** — the request never reached your app: wrong path, or the server binds a hardcoded port instead of `$PORT`. - **The app exits right at boot** — a missing secret. `os.environ["…"]` throws at import time; the logs name it. ## FAQ ### Can I host a Stripe webhook endpoint on Faable Deploy? Yes. It is an ordinary web server with one route. Faable Deploy gives every app a public HTTPS URL with a valid certificate on the first deploy, which is what Stripe requires. Push a Node.js or Python repo — no Dockerfile, no YAML. ### Does an app that scales to zero lose Stripe webhooks? No. Stripe retries failed deliveries with exponential backoff for up to three days, so a cold start delays an event rather than losing it. An endpoint with steady traffic never sleeps in the first place. What it does require is that your handler be idempotent, because delivery is at-least-once and a retried event will arrive twice. ### How do I verify a Stripe webhook signature? HMAC-SHA256 the string `{timestamp}.{raw body}` with the endpoint's `whsec_` signing secret and compare it to the `v1=` value in the `Stripe-Signature` header, rejecting timestamps older than five minutes. In practice, call `stripe.webhooks.constructEvent` (Node) or `stripe.Webhook.construct_event` (Python) and give it the raw bytes. ### Why does my Stripe webhook fail with "No signatures found matching the expected signature"? Almost always because the body was parsed before it was verified. The signature covers the exact bytes Stripe sent, so a re-serialised JSON object will never match. Use `express.raw({ type: 'application/json' })` on that route, mounted before any global JSON parser. ### How do I make a Stripe webhook handler idempotent? Record each `event.id` you have finished processing in your database, and return early when one arrives that you have already recorded. Keep it in the database rather than in memory — an in-process set is lost every time the app redeploys or sleeps. ### Which port should my webhook endpoint listen on? Read the `PORT` environment variable and bind `0.0.0.0`. Faable sets it and routes public HTTPS traffic to it. ## Related - [Deploy a Slack App](guide-slack-bot.md) · [Deploy a Discord Bot](guide-discord-bot.md) · [Deploy a Telegram Bot](guide-telegram-bot.md) · [Deploy a WhatsApp Bot](guide-whatsapp-bot.md) — the same verify-and-acknowledge shape - [Deploy a GitHub App](guide-github-app.md) — the same shape, plus authenticating back as the app - [Deploy an LLM App with Streaming](guide-llm-streaming.md) — the other side of the coin: long responses, sent as they are generated - [Deploy Flask](guide-flask.md) · [Deploy Node.js Express](guide-express.md) · [Deploy FastAPI](guide-fastapi.md) - [Databases & SQLite](databases.md) — where the idempotency record belongs - [Environment & Releases](../environment.mdx) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [What the Builder Expects](../build-requirements.mdx) — detection rules and the `$PORT` contract - [Add authentication to your app](../../auth/get-started.md) — Faable Auth is included in the same subscription --- ## Deploy an LLM App with Streaming Source: https://faable.com/docs/deploy/guides/guide-llm-streaming # Deploy an LLM App with Streaming 🤖 **An app that talks to an LLM is an ordinary web server with one slow dependency — and the way you handle that slowness is what decides whether it works here.** Push a repo and Faable builds it, gives it a public HTTPS URL with a valid certificate at `https://.faable.link`, and puts a [WAF](../security-waf.md) in front of it. This guide covers **Node.js** and **Python**, streaming from the [Claude API](https://docs.claude.com/en/api/overview) to the browser over Server-Sent Events. ## Stream, or you will get a 504 Make this decision first, because it is the difference between an app that works and one that fails on exactly the requests you care about. Faable Deploy waits **60 seconds for your app's response headers**. Past that, the request is cut with a `504`. What it does _not_ limit is how long the response takes after those headers: we measured a response streaming for **96 seconds**, with 12-second gaps between chunks, arriving complete and unbuffered. That gives one rule: > **Send the headers immediately and stream the tokens as they arrive.** The 60-second limit is on time-to-first-byte, not on the length of the response. The failure mode is the obvious implementation: ```js // ❌ Headers only go out once the model has finished. const message = await client.messages.create({ ... }) res.json({ text: message.content[0].text }) ``` That works while answers are short, and starts returning `504` the moment a generation runs long — a bigger prompt, a harder question, a reasoning model taking its time. It looks like a platform timeout and it is really a design choice in the app. ```js // ✅ Headers now, tokens as they come. res.writeHead(200, { 'content-type': 'text/event-stream' }) ``` **So on this platform `stream: true` is not a UX improvement, it is what keeps the request alive.** Which is fortunate, because it is also what users prefer. ## What Faable detects Detection is file-based ([full rules](../build-requirements.mdx)): - **Node.js** — a `package.json` with a `start` script. Supported versions: 20, 22 and 24. - **Python** — a `requirements.txt`, `pyproject.toml` or `Pipfile`, plus a module defining your app object. Supported versions: 3.10, 3.11, 3.12 and 3.13. ## Serve on `$PORT` Faable assigns your app a port and passes it as the `PORT` environment variable. Bind `0.0.0.0` and read it — a hardcoded port means every request times out. ## Node.js `package.json`: ```json { "name": "llm-app", "type": "module", "scripts": { "start": "node server.js" }, "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "express": "^4.19.2" } } ``` `server.js`: ```js import Anthropic from '@anthropic-ai/sdk' import express from 'express' const { PORT } = process.env const client = new Anthropic() // reads ANTHROPIC_API_KEY const app = express() app.use(express.json()) app.use(express.static('public')) app.post('/api/chat', async (req, res) => { // Headers first, before a single token exists. This is the whole trick. res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' }) // A comment line every 15s keeps proxies and browsers from giving up on a // quiet stream while the model is still thinking. const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000) try { const stream = client.messages.stream({ model: 'claude-opus-5', max_tokens: 64000, messages: [{ role: 'user', content: req.body.message }] }) for await (const event of stream) { if ( event.type === 'content_block_delta' && event.delta.type === 'text_delta' ) { res.write(`data: ${JSON.stringify({ text: event.delta.text })}\n\n`) } } res.write('data: [DONE]\n\n') } catch (err) { console.error(err) // The status is long gone — report the failure inside the stream. res.write(`data: ${JSON.stringify({ error: 'generation failed' })}\n\n`) } finally { clearInterval(heartbeat) res.end() } }) app.get('/healthz', (_req, res) => res.json({ ok: true })) app.listen(PORT, '0.0.0.0', () => console.log(`listening on ${PORT}`)) ``` And the browser side, which is smaller than people expect: ```js const res = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message }) }) const reader = res.body.pipeThrough(new TextDecoderStream()).getReader() while (true) { const { value, done } = await reader.read() if (done) break for (const line of value.split('\n')) { if (!line.startsWith('data: ')) continue const payload = line.slice(6) if (payload === '[DONE]') break output.textContent += JSON.parse(payload).text ?? '' } } ``` ## Python Use **FastAPI** here rather than Flask. Streaming responses are exactly the case where an async server earns its keep, and FastAPI is detected and started with `uvicorn` automatically. `requirements.txt`: ```txt fastapi>=0.115 uvicorn[standard]>=0.32 anthropic>=0.71 ``` `main.py` — detection finds `app` and starts it with `uvicorn main:app --host 0.0.0.0 --port $PORT`: ```python import json from anthropic import AsyncAnthropic from fastapi import FastAPI from fastapi.responses import StreamingResponse from pydantic import BaseModel app = FastAPI() client = AsyncAnthropic() # reads ANTHROPIC_API_KEY class Prompt(BaseModel): message: str @app.post("/api/chat") async def chat(prompt: Prompt): async def events(): try: async with client.messages.stream( model="claude-opus-5", max_tokens=64000, messages=[{"role": "user", "content": prompt.message}], ) as stream: async for text in stream.text_stream: yield f"data: {json.dumps({'text': text})}\n\n" yield "data: [DONE]\n\n" except Exception: # The status line is already sent — report failures in-band. yield f"data: {json.dumps({'error': 'generation failed'})}\n\n" # StreamingResponse sends the headers before the generator produces # anything, which is precisely what keeps us inside the 60-second window. return StreamingResponse( events(), media_type="text/event-stream", headers={"cache-control": "no-cache"}, ) @app.get("/healthz") async def healthz(): return {"ok": True} ``` > **If you must use Flask**, remember that the default `gunicorn` worker is synchronous: one worker holds one streaming response for its whole duration, and a second visitor waits. Give it threads with a `startCommand` in [`faable.json`](../build-requirements.mdx) — `gunicorn app:app --bind 0.0.0.0:$PORT --workers 2 --threads 8` — or use FastAPI as above. ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop: ```bash npm i -g @faable/faable faable login faable deploy ``` ## Set your API key Never commit it — an LLM key is a key to your bill: ```bash faable deploy secrets set ANTHROPIC_API_KEY=sk-ant-… ``` Both SDKs read `ANTHROPIC_API_KEY` from the environment, so a bare `new Anthropic()` / `AsyncAnthropic()` picks it up with no wiring. See [Environment & Releases](../environment.mdx) for the variables Faable injects for you. Put a rate limit in front of any endpoint that spends tokens, and authenticate it if it is not meant to be public — an open chat endpoint on the internet is somebody else's free model access, billed to you. [Faable Auth](../../auth/get-started.md) is included in the same subscription, and the [WAF](../security-waf.md) is already in front of your app. ## Things that surprise people **A thinking model looks like a stall.** On Claude Opus 5 extended thinking is on by default and its content is _omitted_ from the stream, so nothing arrives until the reasoning finishes. Users read that as a hang. Either show a spinner from the moment you send the request, or opt into a readable summary: ```js thinking: { type: 'adaptive', display: 'summarized' } ``` ...and render those `thinking_delta` events differently from the answer. **Keep a quiet stream warm.** Nothing in our stack cut a 12-second gap in testing, but browsers, corporate proxies and mobile networks are less patient. The `: ping\n\n` comment line in the Node example above costs nothing and removes the whole class of problem. **Errors arrive after a `200`.** Once the headers are out, you cannot change the status code. Decide upfront how a failure is represented _inside_ the stream and handle it in the client — both examples above send an `error` event. **Buffering is not a problem here, but it is elsewhere.** Faable delivers chunks as your app writes them; we verified SSE and plain chunked responses arriving ~0.13 s after the write. If you later put another CDN or proxy in front of the app, that is the layer to check first. ## What sleeping does and doesn't break After 30 minutes with no requests on the Free plan — 2 hours on Hobby and Pro — your app scales to zero. The next request wakes it. What that means in practice: - **The first request after a sleep is slower.** The container start is added to your time-to-first-byte, so it eats into the same 60-second window — one more reason to send the headers before calling the model rather than after. - **Conversation history is gone.** Anything held in a module-level variable disappears on sleep and on every deploy. Persist it — see [Databases](databases.md). - **Timers don't fire while asleep.** A nightly summarisation job needs an external trigger hitting an endpoint, not an in-process `setInterval`. ## Troubleshooting - **`504` after exactly 60 seconds** — the app is waiting for the whole completion before answering. Stream, and write the headers before you call the model. - **The response arrives all at once at the end** — something between your code and the browser is buffering. Check the client first: `fetch` with a reader streams, `await res.json()` does not. - **Nothing happens for 20 seconds, then everything appears** — that is thinking with the default `display: "omitted"`. Show a spinner or switch to `"summarized"`. - **The second visitor waits for the first** — a synchronous worker holding a stream. Use FastAPI, or add threads to `gunicorn`. - **`401` from the API on the deployed app but not locally** — `ANTHROPIC_API_KEY` was never set as a secret; your laptop had it in the shell. - **The app exits right at boot** — a missing secret. `os.environ["…"]` throws at import time; the logs name it. ## FAQ ### Can I host an AI chatbot or LLM app on Faable Deploy? Yes. It is an ordinary web server that calls a model API. Push a Node.js or Python repo — no Dockerfile, no YAML — and it gets a public HTTPS URL with a valid certificate on the first deploy. The one design requirement is that it streams its responses. ### Does Faable Deploy support Server-Sent Events and streaming responses? Yes, unbuffered. Chunks reach the client as your app writes them — measured at about 0.13 seconds from write to arrival, with a response streaming for over 90 seconds and 12-second gaps between chunks delivered intact. ### Why does my LLM app return a 504 on long generations? Because it waits for the complete answer before sending any response headers. Faable allows 60 seconds for headers; after that the request is cut. Stream instead: send the headers first, then the tokens. There is no limit on how long the response body then takes. ### How long can a request take on Faable Deploy? The 60-second limit applies to time-to-first-byte only. Once your app has sent its response headers, it can keep streaming for as long as it needs. ### Where do I put my Anthropic API key? Set it as a secret — `faable deploy secrets set ANTHROPIC_API_KEY=…` — and let the SDK read it from the environment. Secrets are injected at runtime and never enter your git history or the build image. ### Which port should my LLM app listen on? Read the `PORT` environment variable and bind `0.0.0.0`. Faable sets it and routes public HTTPS traffic to it. ## Related - [Deploy FastAPI](guide-fastapi.md) · [Deploy Node.js Express](guide-express.md) · [Deploy Flask](guide-flask.md) - [Deploy a Telegram Bot](guide-telegram-bot.md) · [Deploy a Discord Bot](guide-discord-bot.md) · [Deploy a Slack App](guide-slack-bot.md) — put a model behind a chat platform - [Databases & SQLite](databases.md) — where conversation history belongs - [Environment & Releases](../environment.mdx) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [What the Builder Expects](../build-requirements.mdx) — detection rules, the `$PORT` contract and `startCommand` - [Add authentication to your app](../../auth/get-started.md) — put your chat endpoint behind a login --- ## Deploy a GitHub App Source: https://faable.com/docs/deploy/guides/guide-github-app # Deploy a GitHub App 🐙 **A GitHub App is a webhook receiver that can also call back into the GitHub API as itself — and both halves are an ordinary web server.** Push a repo and Faable builds it, gives it a public HTTPS URL with a valid certificate at `https://.faable.link`, and puts a [WAF](../security-waf.md) in front of it. GitHub requires a public HTTPS URL to deliver events; you have one from the first deploy. This guide covers **Node.js** and **Python**. ## What makes a GitHub App different Every other guide in this series verifies a signature and answers. A GitHub App does that too — `X-Hub-Signature-256`, HMAC-SHA256 over the raw body, exactly like the [WhatsApp](guide-whatsapp-bot.md) one. If that were all, this page would be short. The difference is **authentication in the other direction**. When your app wants to comment on a pull request, it is not using a personal access token. It goes through three steps: 1. **Sign a JWT** with your App's private key (RS256, valid for at most 10 minutes). This proves you are the App. 2. **Exchange it for an installation access token** — `POST /app/installations/{installation_id}/access_tokens` — which is scoped to the one account that installed you. 3. **Call the API with that token.** It expires after **one hour**, so it is fetched when needed, not stored in config. The installation id arrives inside every webhook payload (`installation.id`), so the loop closes naturally: an event tells you what happened and, in the same breath, which installation to authenticate as. ## Serve on `$PORT`, and answer quickly Faable assigns your app a port and passes it as the `PORT` environment variable — bind `0.0.0.0` and read it. GitHub gives a webhook delivery **10 seconds** before it counts as failed. Acknowledge first and do the work afterwards; the same shape as every other webhook in these guides. ## Storing the private key The App's private key is a **multi-line PEM**, which is the one thing about this setup that usually needs a workaround elsewhere. It does not here — quoted values may span lines: ```bash faable deploy secrets set GITHUB_APP_PRIVATE_KEY="$(cat your-app.private-key.pem)" ``` Or in a `.env` you pass with `faable deploy secrets set --env-file`, with the value quoted across lines: ```dotenv GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- MIIEow... -----END RSA PRIVATE KEY-----" ``` Both work, and there is no need to base64-encode the key first. The other two secrets are ordinary: ```bash faable deploy secrets set \ GITHUB_APP_ID=123456 \ GITHUB_WEBHOOK_SECRET=$(openssl rand -hex 32) ``` The **App ID** is on your App's settings page, the **private key** is generated there (you get one chance to download it), and the **webhook secret** is a random string you choose and paste into the same page. See [Environment & Releases](../environment.mdx) for the variables Faable injects for you. ## Node.js `@octokit/app` handles the JWT and the installation-token exchange for you — it is doing the three steps above, just not in your code. `package.json`: ```json { "name": "github-app", "type": "module", "scripts": { "start": "node server.js" }, "dependencies": { "@octokit/app": "^15.1.0", "express": "^4.19.2" } } ``` `server.js`: ```js import { App } from '@octokit/app' import { createNodeMiddleware } from '@octokit/webhooks' import express from 'express' const { PORT, GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET } = process.env const githubApp = new App({ appId: GITHUB_APP_ID, privateKey: GITHUB_APP_PRIVATE_KEY, // the PEM, newlines and all webhooks: { secret: GITHUB_WEBHOOK_SECRET } }) // `octokit` here is already authenticated as the installation that sent the // event — the JWT and the one-hour token were fetched behind the scenes. githubApp.webhooks.on('issues.opened', async ({ octokit, payload }) => { await octokit.rest.issues.createComment({ owner: payload.repository.owner.login, repo: payload.repository.name, issue_number: payload.issue.number, body: 'Thanks for opening this — someone will take a look.' }) }) githubApp.webhooks.onError(err => console.error('webhook error:', err)) const app = express() // The middleware needs the raw body to check the signature, so mount it // before any express.json(). app.use(createNodeMiddleware(githubApp.webhooks, { path: '/github/webhook' })) app.use(express.json()) app.get('/healthz', (_req, res) => res.json({ ok: true })) app.listen(PORT, '0.0.0.0', () => console.log(`listening on ${PORT}`)) ``` ## Python Here the three steps are explicit, which is worth seeing once. `requirements.txt`: ```txt Flask>=3.0 PyJWT[crypto]>=2.9 requests>=2.32 gunicorn ``` `app.py` — the builder finds `app = Flask(...)` and starts it with `gunicorn app:app --bind 0.0.0.0:$PORT`: ```python import hashlib import hmac import os import time import jwt import requests from flask import Flask, request app = Flask(__name__) APP_ID = os.environ["GITHUB_APP_ID"] PRIVATE_KEY = os.environ["GITHUB_APP_PRIVATE_KEY"] WEBHOOK_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"] API = "https://api.github.com" def is_from_github() -> bool: # request.get_data() is the raw body — the signature covers those bytes. expected = "sha256=" + hmac.new( WEBHOOK_SECRET.encode(), request.get_data(), hashlib.sha256 ).hexdigest() return hmac.compare_digest( expected, request.headers.get("X-Hub-Signature-256", "") ) def app_jwt() -> str: """Step 1: prove we are the App. Backdate `iat` to survive clock skew.""" now = int(time.time()) return jwt.encode( {"iat": now - 60, "exp": now + 540, "iss": APP_ID}, PRIVATE_KEY, algorithm="RS256", ) def installation_token(installation_id: int) -> str: """Step 2: swap the JWT for a token scoped to one installation (1 hour).""" res = requests.post( f"{API}/app/installations/{installation_id}/access_tokens", headers={ "Authorization": f"Bearer {app_jwt()}", "Accept": "application/vnd.github+json", }, timeout=10, ) res.raise_for_status() return res.json()["token"] @app.post("/github/webhook") def webhook(): if not is_from_github(): return "", 401 event = request.headers.get("X-GitHub-Event") payload = request.get_json(silent=True) or {} # GitHub sends this once when you save the webhook URL. if event == "ping": return "", 200 if event == "issues" and payload.get("action") == "opened": # Step 3: call the API as the installation that sent this event. token = installation_token(payload["installation"]["id"]) repo = payload["repository"] requests.post( f"{API}/repos/{repo['full_name']}/issues/" f"{payload['issue']['number']}/comments", headers={"Authorization": f"Bearer {token}"}, json={"body": "Thanks for opening this — someone will take a look."}, timeout=10, ) return "", 200 @app.get("/healthz") def healthz(): return {"ok": True} ``` Cache the installation token if you handle many events — it is good for an hour, and minting one per webhook is a wasted round trip on every delivery. ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop: ```bash npm i -g @faable/faable faable login faable deploy ``` ## Register it with GitHub In **Settings → Developer settings → GitHub Apps → New GitHub App**: - **Webhook URL**: `https://.faable.link/github/webhook` - **Webhook secret**: the same value you set as `GITHUB_WEBHOOK_SECRET` - **Permissions**: only what you use — for the example above, _Issues: Read & write_ - **Subscribe to events**: again only what you handle, e.g. _Issues_ Save, generate a private key, then **Install App** on an account or organisation. GitHub sends a `ping` immediately; a `200` on it means the wiring is right. A [custom domain](../domains/custom-domain.md) works identically — use that URL instead. ### Redeliver instead of guessing Under **Advanced → Recent Deliveries** GitHub shows every request it sent, the exact payload and your response — with a **Redeliver** button. That is the fastest debugging loop you will get for a webhook: fix, deploy, redeliver the same event. It also means you do not need to reproduce anything by hand to test. Deliveries carry `X-GitHub-Delivery`, a UUID that stays the same across redeliveries — the right key if you need to make a handler idempotent (see [the Stripe guide](guide-stripe-webhooks.md), where that matters more). ## What sleeping does and doesn't break After 30 minutes with no requests on the Free plan — 2 hours on Hobby and Pro — your app scales to zero. The next webhook wakes it. What that means in practice: - **The first delivery after a sleep is slower** — the container starts up. It fits comfortably inside GitHub's 10-second window, but keep boot work light and don't mint tokens at import time. - **A cached installation token is gone.** That is harmless — it is a cache, and a fresh one is two HTTP calls away. What matters is not treating it as configuration. - **Timers don't fire while asleep.** A nightly stale-issue sweep needs an external trigger hitting an endpoint, not an in-process `setInterval`. ## Troubleshooting - **Every delivery shows `401` in Recent Deliveries** — the webhook secret in GitHub and `GITHUB_WEBHOOK_SECRET` differ, or the body was parsed before verification. Mount the webhook route before any JSON parser. - **`'Could not deserialize key data'` or `'Invalid key format'` at boot** — the PEM lost its newlines. Set it with `"$(cat key.pem)"`, or quote it across lines in the `.env`; don't paste it as a single line. - **`401 A JWT could not be decoded`** — the JWT is expired or its `iat` is in the future. `exp` must be within 10 minutes and `iat` should be backdated ~60 seconds against clock skew. - **`404` calling the API with a valid token** — the installation token is scoped to one account, and the App may not be installed on that repository, or lacks the permission. Permissions added after installation need to be accepted by the installer. - **`ping` works, nothing else arrives** — the App is not subscribed to that event, or is not installed on the repository. - **Requests time out** — the server binds a hardcoded port instead of `$PORT`, or listens on `127.0.0.1` instead of `0.0.0.0`. ## FAQ ### Can I host a GitHub App on Faable Deploy? Yes. A GitHub App is a webhook receiver plus API calls, which is an ordinary web server. Push a Node.js or Python repo — no Dockerfile, no YAML — and it gets the public HTTPS URL with a valid certificate that GitHub requires for a webhook. ### How do I store a GitHub App private key as an environment variable? Set it directly, newlines included — `faable deploy secrets set GITHUB_APP_PRIVATE_KEY="$(cat key.pem)"`. Faable's secrets accept multi-line values, and in a `.env` file a quoted value may span lines, so there is no need to base64-encode the PEM first. ### How does a GitHub App authenticate to the API? In three steps: sign a JWT with the App's private key (RS256, at most 10 minutes), exchange it at `POST /app/installations/{installation_id}/access_tokens` for an installation access token, then call the API with that token. The token lasts one hour, and the installation id comes in the webhook payload. ### How do I verify a GitHub webhook signature? HMAC-SHA256 the raw request body with your webhook secret and compare it in constant time to the `X-Hub-Signature-256` header, which is prefixed `sha256=`. Verify before parsing — the signature covers the exact bytes GitHub sent. ### How do I debug a GitHub App webhook that isn't working? Use **Advanced → Recent Deliveries** in the App's settings: it shows each request, its payload and your response, and lets you redeliver the same event after a fix. It is faster than trying to reproduce the trigger. ### Which port should my GitHub App listen on? Read the `PORT` environment variable and bind `0.0.0.0`. Faable sets it and routes public HTTPS traffic to it. ## Related - [Deploy a Stripe Webhook Endpoint](guide-stripe-webhooks.md) — the same shape, with idempotency as the main subject - [Deploy a Slack App](guide-slack-bot.md) · [Deploy a Discord Bot](guide-discord-bot.md) · [Deploy a Telegram Bot](guide-telegram-bot.md) · [Deploy a WhatsApp Bot](guide-whatsapp-bot.md) - [Deploy from GitHub](../../deploy/get-started.md) — how Faable itself deploys your repository - [Environment & Releases](../environment.mdx) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [What the Builder Expects](../build-requirements.mdx) — detection rules and the `$PORT` contract - [Add authentication to your app](../../auth/get-started.md) — Faable Auth is included in the same subscription --- ## Deploy a Telegram Bot Source: https://faable.com/docs/deploy/guides/guide-telegram-bot # Deploy a Telegram Bot 🤖 **Run your Telegram bot in webhook mode and Faable Deploy is all the infrastructure it needs.** Push a repo with a small web server in it and Faable builds it, gives it a public HTTPS URL with a valid certificate at `https://.faable.link`, and puts a [WAF](../security-waf.md) in front of it. Telegram requires HTTPS with a trusted certificate to deliver updates; you have one from the first deploy. This guide covers **Node.js** and **Python**, both with the plain Bot API — no framework required — plus notes for `grammY`, `Telegraf` and `python-telegram-bot`. ## Webhooks, not `getUpdates` Make this decision first, because it decides whether your bot works at all. Faable Deploy scales an app to zero once no HTTP request has arrived for a while — **30 minutes on the Free plan, 2 hours on Hobby and Pro** — and wakes it on the next one. A webhook bot fits that exactly: Telegram POSTs an update, the request wakes your app, your app answers. Idle time is free, and a bot that people actually use never sleeps at all. A bot built on `getUpdates` long-polling does the opposite — it makes _outbound_ calls in a loop and receives no inbound traffic at all. Nothing keeps it awake, so it sleeps as soon as that window elapses **on any plan**, and simply stops polling. It also can't be scaled or redeployed cleanly, because Telegram allows only one active `getUpdates` consumer per token. | | `setWebhook` | `getUpdates` polling | | :--------------------- | :------------------------------- | :------------------------------------- | | How updates arrive | Inbound HTTPS POST from Telegram | Outbound loop from your app | | Survives scale-to-zero | Yes — the update wakes the app | No — polling stops when the app sleeps | | Cost while idle | Nothing | Constantly running | | Multiple instances | Fine | Conflicts — one consumer per token | Every major library supports webhook mode. Use it. ## What Faable detects Detection is file-based ([full rules](../build-requirements.mdx)): - **Node.js** — a `package.json` with a `start` script. Supported versions: 20, 22 and 24. - **Python** — a `requirements.txt`, `pyproject.toml` or `Pipfile`, plus a module defining your app object. Supported versions: 3.10, 3.11, 3.12 and 3.13. ## Serve on `$PORT` Faable assigns your app a port and passes it as the `PORT` environment variable. Bind `0.0.0.0` and read it — a hardcoded port means Telegram's deliveries time out and it backs off. ## Node.js `package.json`: ```json { "name": "telegram-bot", "type": "module", "scripts": { "start": "node server.js" }, "dependencies": { "express": "^4.19.2" } } ``` `server.js`: ```js import express from 'express' const { PORT, TELEGRAM_TOKEN, TELEGRAM_WEBHOOK_SECRET } = process.env const API = `https://api.telegram.org/bot${TELEGRAM_TOKEN}` const app = express() app.use(express.json()) app.post('/telegram/webhook', async (req, res) => { // Telegram echoes back the secret you registered with setWebhook. Anyone can // find your URL; only Telegram knows this header. if (req.get('x-telegram-bot-api-secret-token') !== TELEGRAM_WEBHOOK_SECRET) { return res.sendStatus(401) } const message = req.body?.message if (!message?.text) return res.sendStatus(200) // Answering in the response body saves a round trip — Telegram accepts a // method call as the reply to the webhook itself. res.json({ method: 'sendMessage', chat_id: message.chat.id, text: `You said: ${message.text}` }) }) app.get('/healthz', (_req, res) => res.json({ ok: true })) app.listen(PORT, '0.0.0.0', () => console.log(`listening on ${PORT}`)) // For anything you can't answer inline — a slow API call, a second message — // call the Bot API directly. export async function sendMessage(chatId, text) { await fetch(`${API}/sendMessage`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, text }) }) } ``` Using **grammY** or **Telegraf** instead? Keep the same shape and mount their webhook callback rather than calling `bot.launch()` or `bot.start()`, which are the long-polling entry points: ```js // grammY import { webhookCallback } from 'grammy' app.use('/telegram/webhook', webhookCallback(bot, 'express')) // Telegraf app.use(bot.webhookCallback('/telegram/webhook')) ``` ## Python `requirements.txt`: ```txt Flask>=3.0 requests>=2.32 gunicorn ``` `app.py` — the builder finds `app = Flask(...)` and starts it with `gunicorn app:app --bind 0.0.0.0:$PORT`: ```python import os import requests from flask import Flask, jsonify, request app = Flask(__name__) TOKEN = os.environ["TELEGRAM_TOKEN"] WEBHOOK_SECRET = os.environ["TELEGRAM_WEBHOOK_SECRET"] API = f"https://api.telegram.org/bot{TOKEN}" @app.post("/telegram/webhook") def webhook(): # Telegram echoes back the secret registered with setWebhook. if request.headers.get("X-Telegram-Bot-Api-Secret-Token") != WEBHOOK_SECRET: return "", 401 update = request.get_json(silent=True) or {} message = update.get("message") if not message or "text" not in message: return "", 200 # Answering in the response body saves a round trip. return jsonify( method="sendMessage", chat_id=message["chat"]["id"], text=f"You said: {message['text']}", ) @app.get("/healthz") def healthz(): return {"ok": True} def send_message(chat_id: int, text: str) -> None: requests.post(f"{API}/sendMessage", json={"chat_id": chat_id, "text": text}, timeout=10) ``` With **python-telegram-bot**, use `run_webhook` and bind the injected port — not `run_polling`: ```python application.run_webhook( listen="0.0.0.0", port=int(os.environ["PORT"]), url_path="telegram/webhook", secret_token=os.environ["TELEGRAM_WEBHOOK_SECRET"], ) ``` Telegram expects an answer within a few seconds and retries with backoff otherwise. If handling an update is slow — an LLM call, an image to render — answer `200` immediately and do the work in a thread or a queue, then send the result with `sendMessage`. ## Deploy From the dashboard — the normal path: 1. Create a **Project** and an **App** in the [Faable Dashboard](https://dashboard.faable.com). 2. Click **Link repository** and pick your bot's repo. 3. Push to your release branch. Faable builds and takes it live. Or from your laptop: ```bash npm i -g @faable/faable faable login faable deploy ``` ## Set your secrets Never commit your bot token — anyone holding it controls your bot: ```bash faable deploy secrets set \ TELEGRAM_TOKEN=123456:ABC-… \ TELEGRAM_WEBHOOK_SECRET=$(openssl rand -hex 32) ``` `TELEGRAM_TOKEN` comes from [@BotFather](https://t.me/BotFather). `TELEGRAM_WEBHOOK_SECRET` is a random string you generate — Telegram sends it back on every request so your app can tell real updates from anyone who guessed the URL. See [Environment & Releases](../environment.mdx) for the variables Faable injects for you. ## Register the webhook Once the app is live, tell Telegram where to deliver updates. This is a one-off `curl`: ```bash curl -X POST "https://api.telegram.org/bot$TELEGRAM_TOKEN/setWebhook" \ -H 'content-type: application/json' \ -d '{ "url": "https://.faable.link/telegram/webhook", "secret_token": "", "allowed_updates": ["message", "callback_query"] }' ``` Check it took, and see whether Telegram is hitting errors: ```bash curl "https://api.telegram.org/bot$TELEGRAM_TOKEN/getWebhookInfo" ``` `last_error_message` in that response is the fastest way to debug delivery. A [custom domain](../domains/custom-domain.md) works identically — register that URL instead. ## What sleeping does and doesn't break After 30 minutes with no requests on the Free plan — 2 hours on Hobby and Pro — your bot scales to zero. The next Telegram update wakes it. What that means in practice: - **The first update after a sleep is slower** — the container starts up. Telegram's retries absorb it, but keep boot work light: open database connections lazily, not at import time. - **In-memory state is gone.** A conversation step held in a module-level dictionary disappears on sleep and on every deploy. Persist it — see [Databases](databases.md). - **Timers don't fire while asleep.** A bot that sends a daily digest needs an external trigger hitting an endpoint, not an in-process `setInterval` or `JobQueue`. ## Troubleshooting - **`getWebhookInfo` shows `last_error_message: Wrong response from the webhook: 401`** — the secret token header doesn't match `TELEGRAM_WEBHOOK_SECRET`. You probably changed the secret without re-running `setWebhook`. - **Nothing arrives at all** — `setWebhook` was never called, or points at the wrong path. The URL must be the full public HTTPS URL including the route. - **`Conflict: terminated by other getUpdates request`** — something is still long-polling with the same token. Stop it; `setWebhook` and `getUpdates` are mutually exclusive. - **Requests time out** — the server binds a hardcoded port instead of `$PORT`, or listens on `127.0.0.1` instead of `0.0.0.0`. - **The bot answers twice** — you're both replying in the webhook body _and_ calling `sendMessage`. Pick one. - **The app exits right at boot** — a missing secret. `os.environ["…"]` throws at import time; the logs name it. ## FAQ ### Can I host a Telegram bot on Faable Deploy? Yes. Run the bot in webhook mode and it's an ordinary web server. Faable Deploy gives every app a public HTTPS URL with a valid certificate on the first deploy, which is exactly what Telegram's `setWebhook` requires. Push a Node.js or Python repo — no Dockerfile, no YAML. ### Should my Telegram bot use webhooks or getUpdates polling on Faable? Webhooks. Faable scales an app to zero after 30 minutes without an inbound request on the Free plan, or 2 hours on Hobby and Pro. A polling bot receives no inbound traffic at all, so it sleeps on every plan and stops working. A webhook bot is woken by each update, costs nothing while idle, and stays up continuously once it has real traffic. ### Do I need a Dockerfile to deploy a Telegram bot? No. Faable detects Node.js from `package.json` and Python from `requirements.txt`, installs your dependencies and starts the app. A Dockerfile is the escape hatch for stacks the buildpacks don't detect natively, and it requires a paid plan. ### How do I keep my Telegram bot token out of my repo? Set it as a secret with `faable deploy secrets set TELEGRAM_TOKEN=…`, or from the dashboard, and read it from the environment. Secrets are injected at runtime and never enter your git history or the build image. ### How do I verify that an update really came from Telegram? Pass a `secret_token` when you call `setWebhook`. Telegram sends it back on every delivery in the `X-Telegram-Bot-Api-Secret-Token` header; reject any request whose header doesn't match. ### Which port should my Telegram bot listen on? Read the `PORT` environment variable and bind `0.0.0.0`. Faable sets it and routes public HTTPS traffic to it. ## Related - [Deploy a WhatsApp Bot](guide-whatsapp-bot.md) — the same shape, with the WhatsApp Cloud API - [Deploy a Discord Bot](guide-discord-bot.md) — the same shape, over Discord's HTTP interactions - [Deploy a Slack App](guide-slack-bot.md) — the same shape, over Slack's Request URL - [Deploy a Stripe Webhook Endpoint](guide-stripe-webhooks.md) — webhooks with idempotency - [Deploy an LLM App with Streaming](guide-llm-streaming.md) — put a model behind the bot - [Deploy Flask](guide-flask.md) · [Deploy Node.js Express](guide-express.md) · [Deploy FastAPI](guide-fastapi.md) - [Databases & SQLite](databases.md) — where to keep conversation state - [Environment & Releases](../environment.mdx) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) - [What the Builder Expects](../build-requirements.mdx) — detection rules and the `$PORT` contract - [Add authentication to your app](../../auth/get-started.md) — Faable Auth is included in the same subscription --- ## Deploy Uptime Kuma Source: https://faable.com/docs/deploy/guides/guide-uptime-kuma # Deploy Uptime Kuma 📈 **[Uptime Kuma](https://github.com/louislam/uptime-kuma) is a self-hosted status and uptime monitor, and it deploys on Faable straight from a fork — no Dockerfile, no YAML, nothing to edit.** Fork the repository, point an app at it, and the managed Node buildpack does the rest. Two things about this particular app are worth deciding _before_ you deploy, because both are easier to get right now than to fix later: **where its data lives**, and **whether it is allowed to fall asleep**. This guide is honest about both. ## Fork it and deploy it Deploying somebody else's open-source project, unmodified, is a supported and ordinary thing to do here: 1. Fork [`louislam/uptime-kuma`](https://github.com/louislam/uptime-kuma) to your own GitHub account. 2. In the [dashboard](https://dashboard.faable.com), create an app and connect it to your fork — the repository is chosen in the first step of the create form. 3. Deploy. That is the whole build. Uptime Kuma ships a `package.json` with a `start` script, which is exactly what [detection](../build-requirements.mdx) looks for, so it builds with the managed Node buildpack and needs **no paid plan** to build. You do not have to change a single file for it to boot, either. Uptime Kuma reads its port from `PORT` when it is set — `server/config.js` checks `UPTIME_KUMA_PORT`, then `PORT`, then falls back to 3001 — and Faable sets `PORT` for you. The [`$PORT` contract](../runtime.md) is satisfied out of the box. ## Decision 1: MariaDB, not SQLite On first run Uptime Kuma asks you which database to use. **Choose MariaDB/MySQL and point it at a database outside the app. Do not choose SQLite.** SQLite would put your entire monitor history in a file on the instance's disk, and that disk [does not survive](databases.md) a redeploy, a restart, or waking from sleep. The app would work perfectly and then quietly lose every monitor, every notification setting, and every minute of history the first time it restarts — including your admin account. Uptime Kuma bundles the `mysql2` driver, so MariaDB and MySQL work natively with no code changes. Any managed MySQL-compatible database with an EU region will do; the setup screen asks for host, port, database name, username and password. > This is not an Uptime Kuma quirk — it is how the platform works for every app. [Databases & SQLite](databases.md) covers the general rule and the free EU-hosted options. ## Decision 2: a sleeping monitor is not monitoring This is the one that surprises people, so be clear-eyed about it. An app sleeps when **no inbound HTTP request** has reached it for a while, and wakes on the next one. For most apps that is ideal — it costs nothing while idle. For a monitor it is a genuine limitation: **a sleeping Uptime Kuma is not running its checks.** It cannot poll your services, evaluate uptime, or send you a notification about an outage, because nothing of it is running to do so. The word to hold on to is **inbound**. Uptime Kuma's checks go _out_, so they never count as traffic and never keep it awake by themselves. What keeps it awake is somebody — or something — opening it: its dashboard, its status page, an external ping. That makes the plan difference concrete: | You want | Plan | What happens | | :---------------------------------------------------------------------- | :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | A dashboard you open occasionally, checks running only while you use it | Free | Sleeps after **30 minutes** idle, and the platform answers monitor pings on the app's behalf, so it cannot be kept awake from outside | | A monitor that watches your services continuously | **Hobby or Pro** | A four-times longer window (**2 hours**), and inbound traffic reaches your app instead of being answered for it — so its dashboard, its status page or an external ping all keep it running | On Hobby and Pro nothing sleeps while it is being used. The two-hour window only expires if genuinely nothing touches the app for two hours — so if your Uptime Kuma has a status page people load, or you point a second monitor at it, it simply keeps running. If continuous monitoring is the point, Hobby or Pro is the supported way to get it. Pinging your own app from somewhere else to keep it awake does not turn a Free app into a reliable monitor: the checks still only run when something happens to be poking it, you are paying for it in wake time either way, and you have built a monitor whose reliability depends on a second thing you also have to monitor. ## Sizing Uptime Kuma is comfortable in a small instance, but it is a real Node server with a database connection and a scheduler. The free `bi.xs` (0.5 CPU · 1 GB) runs it; if you add a lot of monitors with short intervals, move up the [instance catalog](../pricing.mdx#instances). ## First run The first request lands on `/setup-database`, where you pick MariaDB and enter the connection details. After that Uptime Kuma asks you to create the admin account — do this promptly, since until you do, whoever reaches the URL first can claim it. Your app is served at `https://.faable.link` with a valid certificate from the first deploy, behind the [WAF](../security-waf.md). You can add a [custom domain](../domains/custom-domain.md) whenever you like. ## Keeping your fork current Uptime Kuma releases regularly. Because you deployed a fork, updating is a GitHub operation, not a Faable one: sync your fork with upstream, and the push triggers a new deploy automatically. Your data is untouched — it lives in the database, not in the app. ## Related - [Databases & SQLite](databases.md) — why the filesystem is ephemeral, and where to put your data - [Pricing and plans](../pricing.mdx) — sleep behaviour, instance sizes, what each plan includes - [Build requirements](../build-requirements.mdx) — how detection picks the Node buildpack - [Runtime and secrets](../runtime.md) — the `$PORT` contract and environment variables --- ## Databases & SQLite Source: https://faable.com/docs/deploy/guides/databases # Databases & SQLite 💾 **The short version: your app's filesystem on Faable is writable but ephemeral — anything written to disk is gone the next time your app is redeployed, restarted, or wakes from sleep. Keep your data in a database that lives outside the app.** This guide shows you how, with free EU-hosted options that take minutes to set up. ## Why files don't survive Every deploy runs your app in a fresh instance, built from your latest artifact. The instance's disk is real and writable — temp files, caches and uploads all work — but it belongs to that instance, and instances are replaced: - **On every deploy.** A new release means a new instance with a clean disk. - **When your app sleeps.** After a couple of hours without traffic your app is put to sleep to free resources, and wakes automatically on the next request. Waking starts a fresh instance. - **On restarts.** A crash or an out-of-memory kill restarts the app on a clean disk. This is the same model as Heroku, Vercel and most modern platforms, and it is what makes deploys fast and rollbacks safe. But it means the disk is a scratchpad, not storage. ## What this means for SQLite SQLite stores everything in a single file next to your code — `database.db`, `db.sqlite3`, `dev.db`. That's perfect for local development, and it's what many frameworks and AI code assistants reach for by default: - Flask/Python: `sqlite3.connect("database.db")` - Django: the default `DATABASES` setting points at `db.sqlite3` - Node.js: `better-sqlite3`, `sqlite3` - Prisma: `provider = "sqlite"` with a `file:./dev.db` URL On Faable, that file lives on the ephemeral disk. **The app will deploy and run fine — and then silently lose every row when the instance is replaced.** If your app registers users, takes orders, or stores anything you'd miss, move that data out of the instance before you share the URL. > We're exploring first-class persistence for SQLite apps. Today, the options below are the supported path — and they're genuinely good: both have free tiers and EU regions, matching your app's 100% European hosting. ## Option 1: Neon — serverless Postgres (recommended) [Neon](https://neon.tech) is managed PostgreSQL with a generous free tier and EU regions (Frankfurt, and others). Postgres is the boring, correct choice for members, orders, and anything relational — and every framework speaks it. 1. Create a project in an EU region at [neon.tech](https://neon.tech) and copy the connection string. 2. Add it to your app as a [secret](../runtime.md) named `DATABASE_URL`. 3. Point your app at it. **Flask / SQLAlchemy:** ```python import os from sqlalchemy import create_engine engine = create_engine(os.environ["DATABASE_URL"]) ``` ```txt # requirements.txt — add: sqlalchemy psycopg2-binary ``` **Django** — replace the default SQLite `DATABASES` block: ```python import os import dj_database_url DATABASES = {"default": dj_database_url.config(default=os.environ["DATABASE_URL"])} ``` **Node.js:** ```js import pg from 'pg' const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }) ``` **Prisma** — set `provider = "postgresql"` in `schema.prisma` and keep `url = env("DATABASE_URL")`. ## Option 2: Turso — keep SQLite, hosted If your app is already written against SQLite and you'd rather not switch engines, [Turso](https://turso.tech) hosts your database as [libSQL](https://github.com/tursodatabase/libsql) — SQLite's dialect and semantics, served over the network. Free tier, EU regions available. 1. Create a database in an EU region and copy the database URL and auth token. 2. Add them as [secrets](../runtime.md): `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN`. 3. Swap the local SQLite driver for the libSQL client: **Node.js:** ```js import { createClient } from '@libsql/client' const db = createClient({ url: process.env.TURSO_DATABASE_URL, authToken: process.env.TURSO_AUTH_TOKEN }) await db.execute('SELECT * FROM orders') ``` **Python** — Turso's `libsql` package is a drop-in for the standard `sqlite3` module; see [Turso's Python quickstart](https://docs.turso.tech/sdk/python/quickstart) for the current connect call. Your SQL, schema and queries stay exactly as they are — only the connection changes. ## What the disk IS good for Ephemeral doesn't mean useless. The instance disk is the right place for: - **Caches** — rendered pages, compiled templates, resized images you can regenerate. - **Temp files** — an upload being processed before it's stored elsewhere. - **Unpacked assets** — anything your build or boot step derives from the artifact. The rule of thumb: if losing it would only cost you a recomputation, the disk is fine. If losing it would cost you data, it goes in a database. ## Troubleshooting - **"My users/orders disappeared overnight"** — your app went to sleep and woke on a fresh instance. The data was in a file on the old instance's disk. Move it to a database; the data already lost is unfortunately not recoverable. - **"It worked for days, then reset after I pushed a fix"** — same cause: the deploy replaced the instance. Data written to disk never survives a deploy. - **SQLite file committed to the repo** — a `.db` file in your repository ships _inside_ the artifact, so every deploy resets the database to whatever state is committed. Remove it from the repo once you've moved to a hosted database. ## FAQ ### Can I use SQLite on Faable Deploy? Yes — the file is created and works normally — but only for data you can afford to lose, like caches. The filesystem is ephemeral: the database file is deleted whenever your app is redeployed, restarted, or wakes from sleep. For real data, use a hosted database such as Neon (Postgres) or Turso (hosted SQLite). ### Why did my SQLite data disappear? Your app's instance was replaced — by a deploy, a restart, or by sleeping after inactivity — and the new instance starts with a clean disk. Any file your app wrote, including its SQLite database, is gone. This is by design on Faable, as on Heroku and Vercel. ### Does Faable Deploy offer persistent disks or volumes? Not today. The filesystem is ephemeral by design, which keeps deploys fast and rollbacks safe. We're exploring first-class persistence for SQLite apps; until then, connect a hosted database — the EU-hosted free tiers of Neon and Turso cover most projects. ### Which database providers work with Faable? Any database reachable over the network: managed Postgres or MySQL, hosted SQLite/libSQL, MongoDB Atlas, Redis providers. For European hosting end to end, pick a provider with EU regions — Neon and Turso both offer them — and put the connection string in a [secret](../runtime.md). ### Do I need to change my code to use Turso instead of local SQLite? Only the connection. Turso serves libSQL — SQLite's dialect — so your schema and queries stay the same. In Node.js you replace `better-sqlite3`/`sqlite3` with `@libsql/client`; in Python, Turso's `libsql` package is a drop-in for the `sqlite3` module. ## Related - [Runtime](../runtime.md) — how your app runs, restarts, and secrets - [Environment & Releases](../environment.mdx) — platform variables and release lifecycle - [Deploy Flask](guide-flask.md) · [Deploy Django](guide-django.md) · [Deploy Node.js Express](guide-express.md) - [Pricing](../pricing.md) — instance sizes and plans --- ## Migrate from Vercel to Faable Deploy Source: https://faable.com/docs/deploy/guides/migrate-from-vercel # Migrate from Vercel to Faable Deploy **Short answer:** Migrating from Vercel to Faable Deploy keeps the part you like — push to Git, get a live deploy with automatic SSL — while moving your app from Vercel's frontend-first serverless model to a platform that runs **any stack as an always-on service** (frontend _and_ backend). You connect your GitHub repository, move your environment variables, and re-point your custom domain. Teams move for **European data sovereignty (full GDPR)**, a **built-in Web Application Firewall**, **predictable EUR pricing without surprise egress tiers**, and bundling hosting with [Faable Auth](../../auth/get-started.md) in one subscription. ## Concept mapping | Vercel | Faable Deploy | Notes | | ------------------------------ | --------------------------------------------- | ---------------------------------------------------------------------------- | | Project | App | One repository → one app at `https://.faable.link` | | Git integration | GitHub connection | Push to deploy, same workflow | | Serverless / Edge Functions | Always-on service | Your server process runs continuously, listening on the port Faable gives it | | Environment Variables | Environment Variables | Set per app in the dashboard or with `faable deploy secrets set` | | Custom Domains | [Custom domains](../domains/custom-domain.md) | CNAME/ALIAS + automatic SSL | | Vercel WAF / Firewall (add-on) | [Built-in WAF](../security-waf.md) | OWASP CRS, included | | Analytics / Observability | Logs & monitoring | Logs in the [dashboard](https://dashboard.faable.com) | ## Migration steps 1. **Connect your repository.** Sign in to the [Faable Dashboard](https://dashboard.faable.com/account/register) and link the GitHub repo you deploy on Vercel today. 2. **Make sure your app reads the port Faable provides.** Your server should bind to the `PORT` environment variable rather than a hardcoded port (see the [Express guide](guide-express.md) for the one-line pattern, and [What the Builder Expects](../build-requirements.mdx#-the-port-contract) for the details). Static frontends and most frameworks work with their standard `start` command — no change needed. 3. **Copy environment variables** from your Vercel project settings into the Faable app — in the dashboard, or from the terminal with the [CLI](../../cli.md#secrets): `faable deploy secrets set KEY=VALUE KEY2=VALUE2`. 4. **Deploy.** Push to your branch (or run `faable deploy`); Faable detects the stack, builds, and serves it with free SSL. See [Get Started](../get-started.md) and [What is zero-config CI/CD?](../what-is-zero-config-cicd.mdx). 5. **Move your domain.** Add your [custom domain](../domains/custom-domain.md) in Faable, update the DNS record, and let [SSL](../domains/ssl-certificates.md) provision automatically. Keep the Vercel deployment live until DNS propagates. 6. **Cut over and decommission** the Vercel project once traffic is flowing through Faable. ## What to plan for - **Serverless → long-running process:** if you relied on per-request serverless functions, your backend now runs as a continuous, always-on process. Most frameworks (Next.js, Express, Nuxt, SvelteKit) run unchanged; purely edge-runtime code may need adjusting. - **Build settings:** install/build/start commands carry over; set them on the app if your framework isn't auto-detected. ## Related - [Faable Deploy vs Vercel, Render & Railway](../compare.mdx) - [Get Started with Faable Deploy](../get-started.md) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) --- ## Migrate from Netlify to Faable Deploy Source: https://faable.com/docs/deploy/guides/migrate-from-netlify # Migrate from Netlify to Faable Deploy **Short answer:** Migrating from Netlify to Faable Deploy keeps the Git-driven workflow — connect a repo, push, get a live site with automatic SSL — while moving from Netlify's static-site-plus-functions model to a platform that runs **full-stack apps and backends** as well as static frontends. You connect your GitHub repository, carry over environment variables and redirects, and re-point your domain. Teams move for **European data sovereignty (GDPR)**, a **built-in Web Application Firewall**, **predictable EUR pricing**, and bundling with [Faable Auth](../../auth/get-started.md). ## Concept mapping | Netlify | Faable Deploy | Notes | | ------------------------ | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | Site | App | One repository → one app at `https://.faable.link` | | Continuous Deployment | GitHub connection | Push to deploy | | Netlify Functions | Always-on service | Run your backend as a continuous process on the port Faable provides | | Environment Variables | Environment Variables | Set per app in the dashboard or with `faable deploy secrets set` | | Redirects / `_redirects` | App routing / framework config | Handle redirects in your framework or server | | Custom Domains + SSL | [Custom domains](../domains/custom-domain.md) + [SSL](../domains/ssl-certificates.md) | CNAME/ALIAS + automatic certificates | | — | [Built-in WAF](../security-waf.md) | OWASP CRS, included | ## Migration steps 1. **Connect your repository** in the [Faable Dashboard](https://dashboard.faable.com/account/register). 2. **Read the port Faable provides.** For server-rendered or backend apps, listen on the `PORT` environment variable rather than a hardcoded port (see the [Express guide](guide-express.md), and [What the Builder Expects](../build-requirements.mdx#-the-port-contract) for details). Static sites build and serve with their standard build command — no change needed. 3. **Copy environment variables** from Netlify's site settings — in the dashboard, or from the terminal with the [CLI](../../cli.md#secrets): `faable deploy secrets set KEY=VALUE KEY2=VALUE2`. 4. **Re-create redirects.** Move any `_redirects` / `netlify.toml` rules into your framework's routing or your server, since Faable serves your app directly rather than through Netlify's edge rules. 5. **Deploy** by pushing your branch or running `faable deploy` — Faable detects, builds, and serves with free SSL. See [Get Started](../get-started.md). 6. **Move your domain** via a [custom domain](../domains/custom-domain.md) and let [SSL](../domains/ssl-certificates.md) provision automatically; cut over once DNS propagates. ## What to plan for - **Functions → always-on process:** Netlify Functions become routes in your own backend running as a continuous process. Most full-stack frameworks port directly. - **Edge rules:** redirects, headers, and rewrites configured in Netlify move into your app or framework configuration. ## Related - [Faable Deploy vs Vercel, Render & Railway](../compare.mdx) - [Get Started with Faable Deploy](../get-started.md) · [Custom domains](../domains/custom-domain.md) · [WAF](../security-waf.md) --- ## Deploy pricing Source: https://faable.com/docs/deploy/pricing # Deploy pricing Faable Deploy is part of the unified Faable subscription. The platform fee and billing model are documented on the [Pricing](../platform/pricing.md) page — this page focuses on **what each tier includes for Deploy specifically**, plus the compute catalog and bandwidth allowances. Start deploying for free ## What each plan includes for Deploy | Plan | Deploy entitlements | | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Free** | **up to 3 projects per account** (2 in your first 48 hours) · 1 free `bi.xs` instance per project · catalog limited to `bi.xs` · **managed Node, Python and PHP buildpacks only** · **up to 10 successful deployments per day per project** (resets at 00:00 UTC) · **build artifacts up to 512 MB** · **10 GB egress / month included** | | **Hobby** | Full instance catalog · **Docker, Dockerfile and prebuilt-image deploys** · **unlimited deployments** · **build artifacts up to 2 GB** · **always-on: apps under traffic never sleep** · custom domains per app · **50 GB egress / month included** | | **Pro** | Everything in Hobby · **100 GB egress / month included**, metered **0.18 €/GB** beyond · 99.9 % uptime SLA | ## Docker and bring-your-own-image **Docker and Dockerfile builds — and deploying a prebuilt image — require a paid plan (Hobby or Pro).** On the **Free** plan, apps build with the three managed buildpacks: | Your repository has | Free plan | | :----------------------------------------------------------------- | :------------------- | | `composer.json` | ✅ PHP buildpack | | `package.json` | ✅ Node.js buildpack | | `requirements.txt`, `pyproject.toml`, `Pipfile`, `cerebrium.toml` | ✅ Python buildpack | | `main.py`, `app.py` or `wsgi.py` with no manifest | ✅ Python fallback | | `.php` files with no `composer.json` **and no `Dockerfile`** | ✅ PHP fallback | | Only a `Dockerfile`, or `{"buildpack": "docker"}` in `faable.json` | ❌ Hobby or Pro | | A prebuilt image deployed directly (no source build) | ❌ Hobby or Pro | A free-plan deploy that resolves to the Docker buildpack stops with `Docker and Dockerfile builds — and deploying a prebuilt image — require a paid plan (Hobby or Pro)`. Nothing is wrong with your repository and there is no build log to read: the platform declines the build rather than running it. Two ways forward: - **Deploy with a managed buildpack.** Most projects that carry a Dockerfile also carry a `package.json`, a `requirements.txt` or a `composer.json` — and when both exist at the repository root, [detection prefers the managed buildpack anyway](build-requirements.mdx#-how-detection-works), so those repos deploy on Free with no changes at all. A repository holding _only_ a Dockerfile needs the matching manifest added. **PHP is the exception**: plain `.php` files are a fallback signal that a `Dockerfile` outranks, so a PHP repo shipping one must add a `composer.json` or remove the `Dockerfile`. - **Upgrade to Hobby or Pro.** Your Dockerfile is then built verbatim with BuildKit, exactly as described in [Dockerfile Projects](build-requirements.mdx#-dockerfile-projects). > [!NOTE] > This is a plan entitlement, not a size or rate limit — the [daily deployment quota](#deployments-per-day-free-plan) is untouched by it, and a build declined this way never counts against it. ## Compute catalog Each deployed app is billed per month based on the instance size it requests. The `bi.xs` row is **free on the Free plan** (1 per project); all other sizes require Hobby or Pro. | Name | Size | Bandwidth | Price | | ------------ | -------------- | --------- | --------- | | `bi.xs` | 0.5 CPU · 1 GB | 10 GB | Free plan | | `bi.small` | 1 CPU · 1.5 GB | 50 GB | 25 € | | `bi.base` | 1 CPU · 3 GB | 50 GB | 40 € | | `bi.medium` | 2 CPU · 3 GB | 100 GB | 50 € | | `bi.large` | 2 CPU · 6 GB | 100 GB | 75 € | | `bi.xlarge` | 4 CPU · 8 GB | 1 TB | 90 € | | `bi.2xlarge` | 6 CPU · 16 GB | 1 TB | 120 € | Instances are billed **per month**. ## Deployments per day (Free plan) On the Free plan each project can promote up to **10 successful deployments per calendar day (UTC)**. Once the quota is spent, nothing is dropped: the build still runs to completion and the deployment waits, ready to roll out. It goes live automatically at 00:00 UTC when the counter resets, or immediately if you upgrade. Hobby and Pro have no deployment limits. > [!TIP] > **Only successful deployments count.** A build that fails never touches the quota, so iterating on a broken build can't lock you out for the rest of the day. ## Sleep and always-on An idle app scales to zero and starts again on the next real request — a cold start of a few seconds. Nothing is lost while it sleeps: the release, its environment and any attached volume stay exactly as they were. How long an app has to be idle before that happens depends on the plan: | Plan | Idle before it sleeps | | --------- | --------------------- | | **Free** | **30 minutes** | | **Hobby** | 2 hours | | **Pro** | 2 hours | **Hobby and Pro are always-on: an app that is being used never sleeps.** There is no forced downtime and no daily rest window — as long as traffic keeps arriving, your app keeps running, around the clock. The two-hour window only ever applies to an app nobody is using, and the first request after it wakes it again. Free is shorter by design: a free instance holds its CPU and memory reservation whether or not anyone is using it, so it is released sooner. > [!NOTE] > **Coming to the Free plan: a daily rest window.** Free apps will be required to sleep for **4 hours out of every 24**, including under traffic. This is **not in effect yet** — today a Free app sleeps only when it has been idle for 30 minutes — and we will announce the date before it applies. If your app has to answer around the clock, that is what Hobby is for. **External uptime monitors do not keep a Free app awake.** A ping from a monitoring service (Uptime Kuma, UptimeRobot, Pingdom, StatusCake, Better Stack, Site24x7 and similar) is answered by the platform on the app's behalf, so it never starts the instance: - **`200`** while your latest release is healthy — the app is asleep and will start on real traffic. - **`503`** when the platform knows the app is broken: the promoted deployment failed, or the app has no release yet. Responses carry an `X-Faable-Probe` header (`sleeping` or `unhealthy`) so you can tell them apart from your app's own answers. Your monitor therefore keeps catching the failures that matter on a Free app — a broken release, an app that never deployed — but it does not see errors your code returns while asleep. If you need a monitor to reach the app itself on every check, upgrade to Hobby or Pro: a monitored app is an app under traffic, and on those plans it stays up. ## Build artifact size When Faable builds your app it produces a **build artifact** — a compressed archive holding your application plus its installed dependencies — and your instances boot from it. The size of that archive is capped per plan: | Plan | Maximum build artifact | | --------- | ---------------------- | | **Free** | 512 MB | | **Hobby** | 2 GB | | **Pro** | 2 GB | If a build produces something larger, the deploy stops with an `artifact_too_large` error naming the size it reached and the limit it passed. The build itself is not charged against your [daily deployment quota](#deployments-per-day-free-plan). Most apps are far below these numbers — a typical Node or Python service lands in the tens of megabytes. Artifacts get large when something ships inside the repository that does not need to: bundled media and datasets, checked-in build caches, or development dependencies installed in the production image. Trimming those is usually enough to get back under the limit; if your app genuinely needs more, upgrade the plan. > [!NOTE] > 2 GB is also the platform maximum, so it is the ceiling on every plan. Builds above it are not supported on the artifact runtime. ## Bandwidth Egress is included **per plan**, counted monthly across all of your instances. | Plan | Egress included / month | Beyond the allowance | | --------- | ----------------------- | ---------------------------- | | **Free** | 10 GB | Not metered — move up a plan | | **Hobby** | 50 GB | Not metered — move up to Pro | | **Pro** | 100 GB | Metered at **0.18 €/GB** | Ingress is unlimited and free on every plan. > [!NOTE] > **Only Pro bills for traffic.** Free and Hobby are flat: their allowance is > what the tier includes, and there is no per-GB line that can grow on an > invoice you did not agree to. If a project consistently serves more than its > allowance, that is the signal to move up a tier — Pro is the plan built to > meter and bill traffic. The `Bandwidth` column in the instance catalog above describes the network capacity of each instance size. It is **not** a separate billing allowance: what you are billed against is your plan's monthly figure in the table here. ## Related - [Platform pricing](../platform/pricing.md) — tiers, platform fee, billing model. - [Auth pricing](../auth/pricing.md) — MAU allowances and identity-feature gating. --- ## What is zero-config CI/CD? Source: https://faable.com/docs/deploy/what-is-zero-config-cicd # What is zero-config CI/CD? **Zero-config CI/CD is continuous integration and continuous deployment that works directly from your Git repository without you writing or maintaining any build configuration, pipeline definitions, or server provisioning.** You connect a repo, push code, and the platform automatically detects your stack, builds it, and deploys it to a live URL. ## The terms, briefly - **CI (Continuous Integration)** — every push is automatically built and validated, so changes integrate continuously instead of in big, risky batches. - **CD (Continuous Deployment)** — once a build passes, it is automatically released to a live environment, so shipping is the default rather than a manual event. - **Zero-config** — you don't write `Dockerfiles`, YAML pipelines, or infrastructure-as-code to make CI/CD happen. The platform infers everything it can from your repository. ## Why it matters Traditional CI/CD is powerful but expensive to set up and own: pipeline YAML, build runners, container images, secrets, and deployment scripts all have to be written and kept working. Zero-config CI/CD removes that operational tax so small teams can ship production apps without a dedicated platform engineer. | Traditional CI/CD | Zero-config CI/CD | | ---------------------------------- | ----------------------------- | | Write and maintain pipeline YAML | Push to a branch | | Build and manage container images | Stack auto-detected and built | | Provision and patch servers | No servers to manage | | Configure SSL and domains manually | Free automatic SSL | ## How Faable Deploy implements zero-config CI/CD With [Faable Deploy](get-started.md), you connect a GitHub repository and push your code. Faable detects your framework, builds it, and serves it at `https://.faable.link` with free SSL, a global edge network, autoscaling, and a built-in [Web Application Firewall](security-waf.md) — nothing to configure, no tokens or YAML to write. From a Git push to a live website in minutes. If you outgrow the defaults, you can still reach for [advanced deploy options](github-actions.md) — multi-environment deploys, custom build commands, and secrets. ## Related - [Get Started with Faable Deploy](get-started.md) - [Faable Deploy vs Vercel, Render & Railway](compare.mdx) - [Runtime](runtime.md) --- ## Hosting AI agents in Europe Source: https://faable.com/docs/deploy/ai-agents # Hosting AI agents in Europe **An AI agent in production is a long-running program with two unusual properties: it waits on a model for seconds at a time, and it often has no user watching it.** Those two things — not the model, not the framework — decide which platform can run it. This page covers what to look for, and is honest about where a scale-to-zero platform like [Faable Deploy](get-started.md) is the wrong choice. If you want the code, the [LLM streaming guide](guides/guide-llm-streaming.md) has working Node.js and Python implementations. ## The question that decides everything: what wakes your agent Managed application platforms in Europe — Faable included — put an idle app to sleep and wake it on the next **inbound HTTP request**. That single mechanic is what makes them cheap, and it is also what makes some agents a bad fit. Work out which shape yours is before you choose anything: | Agent shape | What it looks like | Fits scale-to-zero? | | ------------------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | **Request-driven** | An HTTP endpoint: a chat backend, a tool the model calls, a webhook that receives an event and reasons about it | ✅ Yes — each request wakes it, idle time costs nothing | | **Schedule-driven** | Runs on a timer, but triggered from outside (a cron service, a queue that POSTs) | ✅ Yes — the trigger is an inbound request | | **Continuously polling** | Loops on `getUpdates`, polls a queue, or holds a WebSocket open to a provider | ❌ No — see below | The third row is the trap, and it is the shape most "autonomous agent" tutorials produce. An agent that polls makes **outbound** calls in a loop and receives no inbound traffic at all. Nothing resets the idle timer, so the platform puts it to sleep and the loop simply stops — on **any** plan, paid included. It will look like the platform killed your agent. What actually happened is that nothing ever told the platform the agent was working. **The fix is usually to invert the trigger**: have the queue, the scheduler, or the provider call your agent over HTTPS instead of having your agent ask them in a loop. Most providers support webhooks for exactly this reason. If yours genuinely cannot be inverted — a long-lived WebSocket to a broker you do not control, say — you need a platform that bills for a process that is always running, not one that scales to zero. ## What a model call does to your request An agent that calls a model holds a request open for a long time. Every platform in front of your app has an opinion about that, and it is usually expressed as a timeout on your **response headers**, not on the whole response. On Faable Deploy the limit is **60 seconds to the first byte**. What happens after those headers is not capped: a response streaming for **96 seconds, with 12-second gaps between chunks**, arrives complete and unbuffered. So the rule is: > Send the headers immediately, then stream tokens as they arrive. The limit is on time-to-first-byte, not on the length of the answer. An agent that thinks for a while and returns one large JSON blob at the end will start failing with a `504` the moment a generation runs long — a bigger prompt, a harder question, a reasoning model taking its time. It reads as a platform timeout and it is really a design choice in the app. Streaming is not a nicety for an agent; it is what keeps it inside the window. Cold starts land in the same budget: the first request after a sleep adds container start to your time-to-first-byte. One more reason to write the headers before calling the model rather than after. ## Where the agent's memory lives An agent accumulates state — conversation history, scratchpads, tool results, embeddings. On any modern platform the application filesystem is **ephemeral**: it is real and writable, but it belongs to one instance, and instances are replaced on every deploy, every restart, and every wake from sleep. That makes the local disk a scratchpad, not storage. In practice: - **Conversation and task state** → a database outside the app. The [databases guide](guides/databases.md) covers free EU-hosted options that take minutes to set up. - **Vectors and embeddings** → a managed vector store, or Postgres with `pgvector`. - **Model API keys** → [secrets](environment.mdx), never the repository. An agent that keeps its memory in a local SQLite file will appear to work, then quietly forget everything the first time it sleeps. ## What to check on any European platform If data sovereignty is why you are looking at European hosting in the first place, the agent workload deserves more scrutiny than a normal web app, because agents send data to model providers: - **Where the app runs** — compute, storage and backups inside the EU/EEA, with a DPA and a public subprocessor list. - **Where the model runs** — this is the one people miss. Hosting your agent in Frankfurt does not keep your prompts in Europe if the model call goes to a US provider. If that matters for your data, that is a decision about your model provider, not your hosting platform. - **What reaches the logs** — agent traffic tends to carry user content in payloads. Check what the platform retains. ## How Faable Deploy fits [Faable Deploy](get-started.md) runs an agent the same way it runs any other app: connect a GitHub repository, and it detects the stack, builds it, and serves it at `https://.faable.link` with free SSL and a built-in [Web Application Firewall](security-waf.md). Hosting is **100% European with GDPR data sovereignty**, and the same subscription includes [Faable Auth](../auth/get-started.md) if the agent needs to know who is talking to it. What is specific to agents: - **Serve on `$PORT`** and answer HTTP — that is the whole contract. Any framework works; there is nothing agent-specific to configure. - **Idle time is free.** An agent that handles bursts of work and then waits costs nothing while waiting. Apps sleep after **30 minutes** of no requests on Free and **2 hours** on Hobby and Pro, and wake on the next one. - **Streaming works** — Server-Sent Events pass through unbuffered, which is what the 60-second rule above requires. - **Start free.** The Free plan runs a `bi.xs` instance (0.5 CPU, 1 GB) per project with the managed Node, Python and PHP buildpacks. Agents that need a bigger machine, or that ship as a Docker image, need Hobby or Pro — see [pricing](pricing.mdx). **When to choose something else:** if your agent must hold a process open with no inbound traffic and the trigger genuinely cannot be inverted, a scale-to-zero platform is the wrong tool and you will fight it. Pick a host that bills for an always-running process instead. ## Related - [Deploy an LLM app with streaming](guides/guide-llm-streaming.md) — working Node.js and Python code - [Databases & SQLite](guides/databases.md) — where agent state has to live - [What is zero-config CI/CD?](what-is-zero-config-cicd.mdx) — the deployment model this builds on - [Runtime](runtime.md) — sleep, wake, and what the platform expects from your process --- ## European alternatives to Vercel, Render and Railway Source: https://faable.com/docs/deploy/european-alternatives # European alternatives to Vercel, Render and Railway **If you are replacing Vercel, Render or Railway because you need your application and its data to stay in Europe, the hard part is not finding a platform with an EU region — the incumbents have those. It is deciding how much of the stack has to be European for your requirement to actually be met.** This page lays out the field and the questions that separate one answer from another. We build one of these platforms, so read this knowing that. What follows is the honest shape of the market; where Faable Deploy fits is at the end, and it is not the right answer for everyone. ## First: what does "European" have to mean for you? This is the question that decides everything else, and it is worth answering before you compare features. "European hosting" is used for at least three different claims: | The claim | What it means | Usually enough for | | --------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------- | | **EU region** | A US-owned provider runs your workload in an EU datacenter | GDPR data residency, in many organisations | | **European provider on third-party infrastructure** | An EU company operates the platform, but it runs on a hyperscaler | Procurement rules about who you contract with | | **European provider on European infrastructure** | Both the company and the machines are in Europe | Sovereignty requirements that exclude extraterritorial law | Nothing on this list is dishonest, and the first is a real, defensible position for most teams. But they are not interchangeable, and a requirement written as "must be hosted in Europe" rarely says which one it means. Settle that first, because it eliminates most of the field immediately. ## The three categories of European alternative ### 1. European PaaS on its own or European infrastructure Closest in spirit to Vercel or Render — connect a Git repository, push, the platform builds and runs it — but operated by an EU company on infrastructure it controls or sources in Europe. - **[Clever Cloud](https://clever.cloud/)** (France) states that it operates "our own datacenters in France" to meet sovereignty requirements, and deploys "directly from Git with no manual scripts", detecting the language and managing scaling. It also supports Docker applications. - **[Scalingo](https://scalingo.com/)** describes itself as PaaS plus database-as-a-service with "all datacenters in Europe", "no data transfer outside EU" and "European jurisdiction only", including an Outscale SecNumCloud region. - **Faable Deploy** — see below. This is the category to look at when the requirement is sovereignty rather than residency. ### 2. European PaaS running on hyperscaler infrastructure An EU company provides the platform, developer experience and contract, while the compute runs on a large cloud provider. - **[Upsun](https://upsun.com/)** (formerly Platform.sh) describes "Git-driven automation — GitOps for your entire platform" and lets you "deploy to AWS, GCP, Azure, IBM Cloud, or OVHcloud". This category is often the strongest engineering answer for complex applications, and it is worth being clear-eyed about the trade: you get a European counterparty and, depending on the region you pick, American infrastructure underneath. If your requirement is about who you contract with, that is fine. If it is about whose legal system can reach the machines, it may not be. **Northflank**, **Sliplane** and **IONOS Deploy Now** also operate in this broad space; check each one's current region and ownership story against the table above rather than trusting a summary, including this one. ### 3. Self-hosted platforms on European IaaS Open-source platforms — **Coolify**, **Dokku**, **CapRover** and similar — that give you a Vercel-like experience on servers you rent yourself, typically from **Hetzner**, **OVHcloud** or **Scaleway**. Maximum control and usually the lowest bill. The cost is that you now operate it: patching, backups, monitoring, TLS renewal and the 3 a.m. page are yours. This is an excellent answer for a team that has someone to own it, and a poor one for a team that picked a PaaS precisely so nobody would have to. ## The questions that actually separate them Once the category is settled, these are the things that differ in ways that matter later: - **What happens to an idle app.** Some platforms bill a process that runs continuously; others sleep an idle app and wake it on the next request. This changes both your bill and which workloads fit — see [hosting AI agents](ai-agents.mdx) for the case where it matters most. - **Whether security is included or assembled.** A Web Application Firewall, DDoS protection and TLS are either part of the platform or a separate purchase and a separate integration. - **Whether identity is your problem.** Most platforms host your app and stop there; authentication is a second vendor, a second bill and a second data-processing agreement — which, for a European sovereignty requirement, is a second thing to verify. - **Where the logs live.** Application logs routinely carry personal data. A platform hosted in the EU whose observability stack is not is a gap that audits find. - **The DPA and subprocessor list.** Ask for both, and read the subprocessor list — it is where the actual answer to "is this European?" usually is. ## Where Faable Deploy fits [Faable Deploy](get-started.md) sits in the first category: a European company running European infrastructure, with **100% European hosting and GDPR data sovereignty**. You connect a GitHub repository and it detects the stack, builds it, and serves it at `https://.faable.link` with free SSL — [zero-config CI/CD](what-is-zero-config-cicd.mdx), no pipeline files or servers to configure. Two things are unusual enough to be the reason teams pick it: - **The [Web Application Firewall](security-waf.md) is built in**, on every plan, rather than an enterprise add-on or something you put in front yourself. - **[Faable Auth](../auth/get-started.md) ships in the same subscription** — hosting and identity from one provider, under one contract and one DPA, instead of a PaaS plus Auth0 or Clerk. Apps scale to zero when idle — after 30 minutes on Free, 2 hours on Hobby and Pro — and wake on the next request, so idle time costs nothing. [Pricing](pricing.mdx) is a flat monthly fee per plan. **When another option fits better:** if you need a process that runs continuously with no inbound traffic, scale-to-zero will fight you. If you are already deep in a hyperscaler's managed services, a platform in category 2 keeps you next to them. And if you have someone who genuinely wants to operate the platform, category 3 will be cheaper than all of us. For a feature-by-feature comparison against the US incumbents rather than the European field, see [Faable Deploy vs Vercel, Render & Railway](compare.mdx), [vs Heroku](compare-heroku.mdx) and [vs Fly.io](compare-fly-io.mdx). If you are moving an existing project, there are guides for [migrating from Vercel](guides/migrate-from-vercel.mdx) and [from Netlify](guides/migrate-from-netlify.mdx). ## Related - [Faable Deploy vs Vercel, Render & Railway](compare.mdx) - [What is zero-config CI/CD?](what-is-zero-config-cicd.mdx) - [Hosting AI agents in Europe](ai-agents.mdx) - [Pricing](pricing.mdx) --- ## Faable Deploy vs Vercel, Render & Railway Source: https://faable.com/docs/deploy/compare # Faable Deploy vs Vercel, Render & Railway **Short answer:** Faable Deploy is a zero-config platform that builds and hosts apps straight from a GitHub repository — like Vercel, Render, or Railway — but with three differences that matter for European teams: **hosting is 100% in Europe with full GDPR data sovereignty**, a **Web Application Firewall is built in**, and hosting ships in the **same subscription as Faable Auth** (identity) with **support from the engineers who run the platform**. Use this page to decide which fits your project. The comparison is intentionally honest: each tool is excellent at what it was built for. ## Quick comparison | Capability | Faable Deploy | Vercel | Render | Railway | | ---------------------------------------- | --------------------------------- | --------------------------------------- | -------- | ------------------ | | Deploy from Git, zero config | ✅ | ✅ | ✅ | ✅ | | Frontend & backend (Linux containers) | ✅ | Frontend-first (+ serverless functions) | ✅ | ✅ | | Free automatic SSL | ✅ | ✅ | ✅ | ✅ | | Built-in Web Application Firewall (WAF) | ✅ OWASP CRS, included | Add-on / Enterprise | ❌ (DIY) | ❌ (DIY) | | Autoscaling | ✅ | ✅ | ✅ | Vertical/limited | | 100% European hosting + GDPR sovereignty | ✅ | Primarily US company; some EU regions | US-based | US-based | | Bundled identity / auth | ✅ Faable Auth, same subscription | ❌ | ❌ | ❌ | | Pricing currency | EUR, no surprise egress tiers | USD | USD | USD | | Support from platform engineers | ✅ | Tiered / Enterprise | Tiered | Community / tiered | > Competitor capabilities change frequently — always confirm current details on each vendor's own pricing and docs. ## When Faable Deploy is the better fit - **You need European data sovereignty.** Your data stays in Europe, under European law, with full GDPR compliance — not a US company with optional EU regions. - **You want one platform, not five.** Hosting, authentication ([Faable Auth](../auth/get-started.md)), SSL, and a [Web Application Firewall](security-waf.md) come together in a [single subscription](../platform/pricing.md), instead of stitching together a host, an auth vendor, and a security layer. - **You ship full-stack apps and AI agents**, not only frontends — your code runs in Linux containers with autoscaling. - **You want to talk to a human engineer**, not a bot, when something breaks. ## When another tool might fit better - **Vercel** is hard to beat for pure frontend / Next.js edge rendering and preview-deploy DX if European hosting and a bundled WAF aren't requirements. - **Render** is a mature general-purpose PaaS with managed databases and background workers if you want those primitives first-party. - **Railway** offers a very fluid usage-based experience and a large template ecosystem for spinning up infrastructure quickly. ## How deploying works on Faable Connect your GitHub repository, push your code, and Faable detects your stack, builds it, and serves it at `https://.faable.link` with free SSL — no Dockerfiles, YAML, or servers to provision. Deploys authenticate via OIDC and resolve the app from the linked repository. See **[Get Started with Faable Deploy](get-started.md)** to ship your first app, or the [Deploy pricing](pricing.md) for the instance catalog and bandwidth allowances. --- ## Faable Deploy vs Heroku Source: https://faable.com/docs/deploy/compare-heroku # Faable Deploy vs Heroku **Short answer:** Faable Deploy and Heroku share the same core promise — push your code and it runs, no servers to manage — but Faable is a **modern, 100% European alternative** built for today's pricing and compliance expectations. Faable Deploy runs your app in **Linux containers from a Git push**, includes a **built-in Web Application Firewall**, hosts **entirely in Europe under GDPR**, prices in **EUR**, and **bundles [Faable Auth](../auth/get-started.md)** in the same subscription. ## Quick comparison | Capability | Faable Deploy | Heroku | | ---------------------------------------- | --------------------------------- | -------------------------------------- | | Deploy from Git, zero config | ✅ | ✅ | | Frontend & backend (Linux containers) | ✅ | ✅ (dynos) | | Free automatic SSL | ✅ | ✅ | | Built-in Web Application Firewall (WAF) | ✅ OWASP CRS, included | ❌ (DIY / add-on) | | Autoscaling | ✅ | ✅ (higher tiers) | | 100% European hosting + GDPR sovereignty | ✅ | US-based (Salesforce); some EU regions | | Bundled identity / auth | ✅ Faable Auth, same subscription | ❌ (add-ons) | | Pricing currency | EUR | USD | | Support from platform engineers | ✅ | Tiered | > Competitor capabilities change frequently — always confirm current details on each vendor's own pricing and docs. ## When Faable Deploy is the better fit - **You need European data sovereignty** under GDPR, not a US platform with optional regions. - **You want security included** — a [built-in WAF](security-waf.md) instead of bolting on add-ons. - **You want one platform** — hosting, [auth](../auth/get-started.md), SSL, and WAF in a [single subscription](pricing.md). - **You're modernizing off Heroku** and want a comparable push-to-deploy workflow without re-platforming onto raw cloud infrastructure. ## When Heroku might fit better - You depend on **Heroku's add-on marketplace** and managed data services as first-party primitives. - Your team is standardized on Heroku tooling and EU hosting isn't a requirement. ## How deploying works on Faable Connect your GitHub repository, push your code, and Faable detects your stack, builds it, and serves it at `https://.faable.link` with free SSL — no Dockerfiles or servers to provision. See [Get Started](get-started.md) and [What is zero-config CI/CD?](what-is-zero-config-cicd.mdx). ## Related - [Faable Deploy vs Vercel, Render & Railway](compare.mdx) · [Faable Deploy vs Fly.io](compare-fly-io.mdx) - [Migrate from Vercel](guides/migrate-from-vercel.mdx) · [Migrate from Netlify](guides/migrate-from-netlify.mdx) --- ## Faable Deploy vs Fly.io Source: https://faable.com/docs/deploy/compare-fly-io # Faable Deploy vs Fly.io **Short answer:** Faable Deploy and Fly.io both run apps in containers close to your users, but they sit at different levels of abstraction. Fly.io gives you fine-grained control over machines, regions, and networking — powerful, but more to manage. Faable Deploy is **zero-config**: push to Git and it builds and serves your app, with a **built-in Web Application Firewall**, **100% European hosting under GDPR**, **EUR pricing**, and **bundled [Faable Auth](../auth/get-started.md)** — trading low-level control for simplicity. ## Quick comparison | Capability | Faable Deploy | Fly.io | | ------------------------------------------- | --------------------------------- | ------------------------------- | | Deploy from Git, zero config | ✅ | Config-driven (`fly.toml`, CLI) | | Frontend & backend (Linux containers) | ✅ | ✅ (Firecracker microVMs) | | Free automatic SSL | ✅ | ✅ | | Built-in Web Application Firewall (WAF) | ✅ OWASP CRS, included | ❌ (DIY) | | Autoscaling | ✅ | ✅ (configurable) | | Low-level infra control (regions, machines) | Abstracted away | ✅ Fine-grained | | 100% European hosting + GDPR sovereignty | ✅ | US-based; EU regions available | | Bundled identity / auth | ✅ Faable Auth, same subscription | ❌ | | Pricing currency | EUR | USD | > Competitor capabilities change frequently — always confirm current details on each vendor's own pricing and docs. ## When Faable Deploy is the better fit - **You want simplicity over control.** Push to deploy, no `fly.toml`, machine sizing, or region orchestration to manage. - **You need European data sovereignty** under GDPR with EUR billing. - **You want security and auth included** — a [built-in WAF](security-waf.md) and [Faable Auth](../auth/get-started.md) in one [subscription](pricing.md). - **You ship standard web apps and AI agents** and don't need microVM-level tuning. ## When Fly.io might fit better - You want **granular control** over regions, machine sizes, and networking, and you're comfortable managing configuration. - You need specific low-level capabilities (custom Firecracker setups, advanced multi-region orchestration). ## Related - [Faable Deploy vs Vercel, Render & Railway](compare.mdx) · [Faable Deploy vs Heroku](compare-heroku.mdx) - [Get Started with Faable Deploy](get-started.md) · [What is zero-config CI/CD?](what-is-zero-config-cicd.mdx) --- ## Custom Domain Source: https://faable.com/docs/deploy/domains/custom-domain # Custom Domain **To serve your Faable app on your own domain, add the domain in the dashboard and point a DNS record at Faable.** Go to [Faable Dashboard > Projects > Domains](https://dashboard.faable.com), create a Domain entry, and link it to your deployed Faable App. Faable then issues an [SSL certificate](ssl-certificates.md) automatically, so your site is served over HTTPS. ## Subdomains (CNAME) For a subdomain such as `www.example.com`, create a `CNAME` record pointing to the target shown in the dashboard: ```txt www IN CNAME .domains.faable.link. ``` 🚀 Your site is ready at: `https://www.example.com` ## Apex domains (ALIAS) If your domain is an apex (root) domain such as `example.com`, create an `ALIAS` record instead of a `CNAME`, because the DNS standard does not allow `CNAME` at the zone apex: ```txt . IN ALIAS .domains.faable.link. ``` Check with your DNS provider that it supports `ALIAS` (sometimes called `ANAME` or "CNAME flattening") at the apex — it is not universally available. Providers that support it include Cloudflare, Route 53, and DNSimple, among others. If yours does not, use the `www` subdomain with a redirect from the apex. ## Verification and propagation - After creating the record, Faable verifies the domain and provisions the certificate. This usually completes within minutes once DNS is correct. - **DNS propagation** can take anywhere from a few minutes up to the record's **TTL** (time to live). Lowering the TTL _before_ you migrate makes later changes propagate faster. - You can check the live record with `dig www.example.com CNAME +short` or `dig example.com ALIAS +short`. ## Troubleshooting - **Domain stuck "unverified":** the DNS record is missing or points to the wrong target. Re-check the exact `.domains.faable.link.` value in the dashboard (note the trailing dot) and that you edited the right zone. - **Certificate not issued yet:** verification must succeed first; a misconfigured record blocks SSL. See [SSL certificates](ssl-certificates.md). - **Apex `CNAME` rejected by provider:** use an `ALIAS`/`ANAME` record, or point `www` to Faable and redirect the apex to `www`. - **Old site still showing:** you're seeing cached DNS. Wait for the previous TTL to expire, then re-check with `dig`. - **Conflicting records:** remove any existing `A`/`AAAA`/`CNAME` on the same name that point elsewhere, since they override or conflict with the Faable record. ## Related - [SSL Certificates](ssl-certificates.md) - [Get Started with Faable Deploy](../get-started.md) --- ## SSL Certificates Source: https://faable.com/docs/deploy/domains/ssl-certificates # SSL Certificates **Every Faable domain gets a free, automatically managed SSL certificate, so your app is served over HTTPS without any setup.** Faable issues and **renews certificates automatically** for your `*.faable.link` URLs and your [custom domains](custom-domain.md) once their DNS is verified. Enterprise teams can optionally upload their own certificate instead. ## Automatic (managed) certificates For most teams there is nothing to do: when you add a [custom domain](custom-domain.md) and the DNS record is correct, Faable provisions a certificate and keeps it renewed before expiry. Managed certificates cover both subdomains and apex domains. ## Uploading a custom certificate (Enterprise) Enterprise teams can serve their own certificate on a custom domain rather than the one Faable generates. Upload it from the [domains](https://dashboard.faable.com/domains) configuration page in the [Faable Dashboard](https://dashboard.faable.com). To upload a custom certificate, provide three pieces: 1. The **private key** for the certificate. 2. The **certificate** itself. 3. The **Certificate Authority (CA) root/chain certificate** — provided by your certificate issuer, separate from the domain's core certificate. It may be part of their download or available on their site. Paste the contents of each file directly into the corresponding input. The certificate and private key are extracted from the [PEM](https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail) files your issuer provides, in this format: `certificate.pem`: ``` -----BEGIN CERTIFICATE----- -----END CERTIFICATE----- ``` `private-key.pem`: ``` -----BEGIN PRIVATE KEY----- -----END PRIVATE KEY----- ``` ## SSL best practices - **Wildcard certificates** can be uploaded to cover multiple subdomains at once. - You can include **additional Common Names (CNs)** for subdomains that are not managed by the Faable platform. - A certificate with an **explicitly defined subdomain** is prioritized over a wildcard certificate when both are valid for a given subdomain. - Include the **full chain** (leaf + intermediates) so all clients can validate the certificate. ## Troubleshooting - **"Not secure" / certificate not provisioned:** the domain must be verified first. Confirm the [custom domain](custom-domain.md) DNS record is correct — SSL can't be issued until verification succeeds. - **Browser certificate warning after upload:** the chain is incomplete. Re-upload including the CA root/intermediate certificate. - **Wildcard not matching a host:** a more specific certificate takes precedence; check whether an explicit-subdomain certificate is also installed. - **Certificate "expired":** managed certificates renew automatically; for uploaded custom certificates, you are responsible for replacing them before expiry. ## Related - [Custom Domain](custom-domain.md) - [Get Started with Faable Deploy](../get-started.md) --- ## Runtime Source: https://faable.com/docs/deploy/runtime # Runtime This page describes how your app runs on Faable Deploy — the Node.js versions we support, how to set environment variables, and how we keep your app running. ## Node.js Node versions adhere to Semver, the semantic versioning convention popularized by GitHub. Semver uses a version scheme in the form `MAJOR.MINOR.PATCH`. Faable currently supports **Node.js 20, 22 and 24**. The version you request in `engines.node` is resolved to the latest release of that major at deploy time, and your app runs on Faable's runtime image for that major. Requesting a major outside the supported list fails the deploy. ## Specifying a Node.js Version Always specify a Node.js version that matches the runtime that you’re developing and testing with. To find your version locally: ```bash node --version v20.9.0 ``` To specify the version of Node.js to use on Faable, use the engines section of the `package.json`. Drop the v to save only the version number. ```json { "name": "example-app", "description": "a really cool app", "version": "1.0.0", "engines": { "node": "20.x" } } ``` > If a Node version isn't specified in the engines section, Faable uses the Node version wherever the deploy runs. Pin `engines.node` so every deploy uses the same version — see [What the Builder Expects in Your Repo](build-requirements.md). ## Environment variables You can set environment variables for your App in the [Dashboard](https://dashboard.faable.com) ## App Manager The app manager keeps apps running automatically; so operating your app is maintenance-free. The Runtime instantiates one app per region unless it is specified in redundancy options. In the case one app container craashes, make sure there's at least two app instances running. Otherwise app will be unresponsive for the restart period. ## Restarting Faable Deploy Runtime implements an incremental restart policy for crashing apps. - When an app crashes it will be continuously restarted. - If an app keeps restarting for 5 minutes, it will be stopped and marked as crashed. Crashed apps must be restarted manually. After an app exits, the App Manager restarts them with an exponential back-off delay (10s, 20s, 40s, …), that is capped at five minutes. Once an app has executed for 10 minutes without any problems, the manager resets the restart backoff timer for that app. In case the app keeps incrementing the back-off delay up to five minutes, it will be marked as crashed and needs to be restarted manually. --- ## What the Builder Expects in Your Repo Source: https://faable.com/docs/deploy/build-requirements # What the Builder Expects in Your Repo 📦 When you run `faable deploy` (or push to a linked repo), your project sources are uploaded and Faable's remote builder inspects them, detects the stack and builds your app — no Docker on your machine, no CI build step. **Detection is file-based** — this page describes exactly which files the builder looks for and what it does with them, so you can shape your repo to deploy with zero configuration. ## 🔍 How Detection Works Deploys are built by **buildpacks** (Heroku/CNB-style): each one checks for its trigger files at the project root, **in order** — the first match decides how your app is built: | Priority | File found | Buildpack | | :------- | :------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------- | | 1 | `composer.json` | **PHP** (see below) | | 2 | `package.json` | **Node.js** (see below) | | 3 | `requirements.txt`, `pyproject.toml`, `Pipfile`, or `cerebrium.toml` | **Python** (see below) | | 4 | `Dockerfile` | **Docker** — your Dockerfile, built verbatim · **Hobby or Pro** ([why](pricing.md#docker-and-bring-your-own-image)) | | 5 | `index.php`, or any `.php` file in the root or `public/` (fallback) | **PHP** without dependency install (see below) | | 5 | `main.py`, `app.py`, or `wsgi.py` (fallback, no manifest at all) | **Python** without dependency install (see below) | | 6 | `index.html` in the root, `public/`, `dist/`, `build/` or `_site/` | **Static** — the files are the site, served by nginx (see below) | | — | None of the above | ❌ Deploy fails with a diagnostic listing what was looked for, what your repo contains, and any config from another platform it recognizes | > [!IMPORTANT] > The order matters: if your repo has both a `package.json` and a `Dockerfile`, it is built as a **Node.js** project and the Dockerfile is ignored. The Dockerfile path is the escape hatch for stacks the buildpacks don't detect natively. > > **PHP is the exception**: a `Dockerfile` beside a PHP project wins, and the app keeps building from your own image. PHP repositories that already ship one are running on it — detection never re-platforms them. Force `"buildpack": "php"` in `faable.json` to take the managed runtime instead. > [!WARNING] > **The Docker buildpack requires a paid plan.** Docker and Dockerfile builds — and deploying a prebuilt image — are available on **Hobby and Pro**. Free apps build with the managed Node, Python and PHP buildpacks; a free-plan deploy that resolves to Docker stops before the build with a plan message rather than a build error. See [Docker and bring-your-own-image](pricing.md#docker-and-bring-your-own-image). Note the precedence above works in your favour here: a repo with both a `package.json` and a `Dockerfile` is a Node.js build, so it deploys on Free unchanged. The PHP exception cuts the other way, though — a `Dockerfile` beside a PHP project wins, so a repo of plain `.php` files with a `Dockerfile` resolves to Docker and needs the `Dockerfile` removed (or a `composer.json` added) to build on Free. You can skip detection entirely and **force a buildpack** — `php`, `node`, `python`, `docker`, or `static` — with the `buildpack` field in `faable.json`: ```json { "buildpack": "docker" } ``` A forced buildpack still runs its own detection, so the plan is computed from your real project files — forcing `docker` without a `Dockerfile` fails with a clear error. Forcing `docker` is subject to the same plan requirement as detecting it. --- ## 🟢 Node.js Projects A minimal repo the builder accepts: ``` my-app/ ├── package.json ← triggers Node detection └── server.js ``` ```json { "name": "my-app", "scripts": { "build": "tsc", "start": "node server.js" }, "engines": { "node": "22.x" } } ``` What each piece means to the builder: | Field | Required | What the builder does with it | | :-------------- | :------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | **Yes** | Deploy fails at detection with `Missing name in package.json` without it. | | `engines.node` | No | Resolved to a concrete Node release (e.g. `22.x` → latest 22). Supported majors: **20, 22, 24**. If omitted, the builder uses its own current Node version, which changes as the platform upgrades — pin it. | | `scripts.build` | No | If present, the builder runs `npm run build` before packaging, with your app's environment variables available to the build. | | `scripts.start` | No | The app runs `npm run start` by default. If absent and a static framework is detected, a static serve command is generated instead. | **Dependencies** are installed by the builder when no `node_modules` is present in the uploaded sources (for a monorepo, a `node_modules` at the workspace root counts). The lockfile picks the tool: `package-lock.json`/`npm-shrinkwrap.json` → `npm ci`, `yarn.lock` → `yarn install --frozen-lockfile`, `pnpm-lock.yaml` → `pnpm install --frozen-lockfile`, no lockfile → `npm install`. Dev dependencies are always included (build tools live there). If a frozen install fails — e.g. a stale lockfile — the builder retries with a plain `npm install` rather than failing the deploy. **Start command precedence**: `startCommand` in `faable.json` → framework-detected static serve → `npm run start`. ### Static Frontends If `package.json` has **no `start` script**, the builder looks at your dependencies (including `devDependencies`, and hoisted monorepo dependencies) to detect a static framework and serves its build output automatically: | Framework detected | Build output | Served with | | :--------------------------------- | :----------------------- | :--------------------------- | | Astro | `dist/` | `astro preview` | | Gatsby | `public/` | `gatsby serve` | | Create React App (`react-scripts`) | `build/` | static server, SPA fallback | | Vue CLI (`@vue/cli-service`) | `dist/` | static server, SPA fallback | | Angular | read from `angular.json` | static server, SPA fallback | | Vite | `dist/` | `vite preview`, SPA fallback | For Angular, the output path comes from `angular.json` (`architect.build.options.outputPath`), with the `browser/` subfolder handled automatically for Angular 17+ builders. SPA fallback means unknown routes rewrite to `index.html` — Astro and Gatsby build real per-page files, so they 404 instead. Next.js is the exception: it always runs as a server (never static), with a persistent cache for `.next/cache` managed by the platform. When your start script is the stock `next start`, the builder ships Next's compact standalone output automatically; a custom start command (or `"next": { "standalone": false }` in `faable.json`) opts out and ships the full workspace. And remember — defining a `start` script disables static serving entirely; the builder trusts your script. ### Monorepos (Root Directory) To deploy one app from a repository holding several, declare its **Root Directory** with `rootDir` in a `faable.json` at the repository root: ```json { "rootDir": "apps/api" } ``` This works for both shapes a monorepo comes in: - **A workspace** (Turborepo, npm/yarn/pnpm workspaces) — a `package.json` at the repository root ties the packages together. - **Sibling folders** — `client/` and `server/`, each self-contained with its own `package.json` and lockfile, and nothing at the repository root. The builder then: - installs dependencies at the **workspace root** when there is one (hoisted packages resolve normally), and **inside the Root Directory** when the repository root has no `package.json`, - detects, builds and packages the app **inside the Root Directory**, - for Next.js, points the standalone output tracing at the workspace root so shared packages are included. `rootDir` is honored by every buildpack: Node, Python, PHP and a `Dockerfile` in a subdirectory. There is only ever **one** `faable.json`, and it lives at the repository root — `startCommand`, `buildCommand` and the rest are read from there too, not from inside the Root Directory. The path is relative to the repository root — absolute paths and `..` segments are rejected, and the build fails with a clear error if the directory doesn't exist. > [!NOTE] > For monorepos that deploy **several Faable apps from the same repository**, a single `rootDir` can't distinguish them — a platform-managed Root Directory is set per app instead (it takes precedence over `faable.json`). Contact support to set it up. --- ## 🐍 Python Projects Detected by `requirements.txt`, `pyproject.toml`, `Pipfile`, or `cerebrium.toml` (first match wins when several exist). The builder creates a virtual environment and installs dependencies into it according to the manifest: | Manifest | Install | | :----------------- | :--------------------------------------- | | `requirements.txt` | `pip install -r requirements.txt` | | `pyproject.toml` | `pip install .` | | `Pipfile` | `pipenv install --system --deploy` | | `cerebrium.toml` | the `[cerebrium.dependencies.pip]` table | A `buildCommand` in `faable.json` replaces the manifest install entirely — use it when you need a custom install step. **Python version**, first match wins: 1. `runtime.txt` — must be in the form `python-` (e.g. `python-3.12.1`) 2. `.python-version` 3. The manifest's own version (e.g. `python_version` in `cerebrium.toml`) 4. `requires-python` in `pyproject.toml` 5. Default: `3.11` Supported Python minors: **3.10, 3.11, 3.12 and 3.13** — the app runs on Faable's shared runtime image for the resolved minor. **Start command**, first match wins: 1. `startCommand` in `faable.json` 2. A `web:` line in a `Procfile` 3. A start command declared by the manifest (e.g. the Cerebrium `entrypoint`) 4. Framework auto-detection: - **Django** (`manage.py` + a package with `wsgi.py`) → `gunicorn .wsgi:application --bind 0.0.0.0:$PORT` - **FastAPI / Starlette** → `uvicorn :app --host 0.0.0.0 --port $PORT` - **Flask** → `gunicorn :app --bind 0.0.0.0:$PORT` For FastAPI and Flask the builder finds your app module by checking, in order: `main.py`, `app.py`, `asgi.py`, `wsgi.py`, `application.py`, `server.py`, `app/main.py`, `app/app.py`, `src/main.py` — preferring the file that actually defines `app = FastAPI(...)` / `app = Flask(...)`. `gunicorn`/`uvicorn` are installed automatically if the start command needs them and they're not in your dependencies. If no framework is recognized and there's no `Procfile` or `startCommand`, the deploy fails and asks you to provide one. ### Cerebrium Projects A repo shaped for [Cerebrium](https://www.cerebrium.ai) — a `cerebrium.toml` plus a Python entrypoint, no `requirements.txt` — deploys out of the box. Faable reads the `[cerebrium.dependencies.pip]` table (and `[cerebrium.dependencies.paths] pip` when present) to install dependencies, `python_version` from `[cerebrium.deployment]`, and the `[cerebrium.runtime.custom] entrypoint` as the start command when set. `apt` and `conda` tables are not installed. If your repo also has a classic manifest, the classic manifest wins. Because Faable runs **web services**, your project still needs a web entrypoint (a FastAPI/Flask app, a `Procfile`, or `startCommand` in `faable.json`) — a bare GPU function without one fails with an explanation. ### Python Without a Manifest A lone `main.py` / `app.py` / `wsgi.py` with no dependency manifest at all is picked up by the Python fallback: the app builds **without installing your dependencies** (the deploy logs warn loudly; the web server itself — `uvicorn`/`gunicorn` — is still installed when the detected start command needs it). Framework detection still works by reading the entrypoint file itself. If your app imports anything beyond the standard library, add a `requirements.txt`. Note this fallback loses against a `Dockerfile` — an explicit Dockerfile always wins over a loose `.py` file. --- ## 🐘 PHP Projects > [!TIP] > Deploying Laravel or a plain PHP site? [Deploy a PHP App](guides/guide-php.md) walks through both end to end, including the environment a stock Laravel needs. Detected by `composer.json`, or — with no `composer.json` at all — by any `.php` file in the repository root or in `public/`. Your app runs on Faable's shared PHP runtime: **Apache with mod_php**, so your `.htaccess` rules work as written (`mod_rewrite`, `mod_headers` and `mod_expires` are enabled). A minimal repo the builder accepts is exactly the one you already have: ``` my-site/ ├── index.php ← triggers PHP detection ├── login.php └── css/style.css ``` ### Document Root The builder points Apache at the first of these that holds an `index.php`, and falls back to the repository root: | Layout | Document root | | :------------------------------------------ | :------------------ | | `public/index.php` (Laravel, Symfony, Slim) | `public/` | | `public_html/index.php` | `public_html/` | | `web/index.php` | `web/` | | `.php` files in the repository root | the repository root | When the repository root is the document root, everything beside your PHP is web-reachable — so the runtime denies dotfiles (`.env` first of all), `.sql`/`.sqlite`/`.db`/`.log`/`.ini`/`.sh`/`.yml` files, `composer.json`/`composer.lock`, `package.json`, `faable.json`, `Dockerfile`, `Procfile`, and the `vendor/`, `node_modules/` and `.git/` directories. Directory listings are off. Move anything that isn't web content out of the document root anyway — a `public/` layout is the safe default. ### Dependencies With a `composer.json`, the builder runs: ``` composer install --no-dev --optimize-autoloader --no-interaction --no-progress ``` Your scripts run (Laravel's `package:discover`, Symfony's `cache:clear`), so `vendor/` ships with the app. A `buildCommand` in `faable.json` replaces that install entirely. Without a `composer.json` nothing is installed — plain PHP deploys as it is, and the build log says so. ### PHP Version First match wins: 1. `.php-version` 2. `config.platform.php` in `composer.json` 3. `require.php` in `composer.json` — the platform default is used whenever the constraint allows it, otherwise the highest supported version that satisfies it 4. Default: `8.3` Supported PHP versions: **8.2, 8.3 and 8.4**. A pin outside that list fails at detection with the supported list, rather than building an app nothing can run. ### Extensions The runtime ships `bcmath`, `exif`, `gd`, `intl`, `mysqli`, `opcache`, `pdo_mysql`, `pdo_pgsql` and `zip`, on top of what the official PHP image includes (`curl`, `mbstring`, `openssl`, `session`, `sqlite3`/`pdo_sqlite`, `xml`…). Run `php -m` in a deployed script to see the exact set. An app that needs more ships its own `Dockerfile` (Hobby or Pro). Defaults worth knowing: `memory_limit` 256M, `max_execution_time` 60s, `upload_max_filesize` and `post_max_size` 32M, and **`display_errors` is off** — errors go to the deploy logs (`faable deploy logs`), never to your visitors. ### What PHP Apps Have to Bring Themselves Two things trip up PHP projects more than any other stack, and neither is a build error — the deploy succeeds and the app misbehaves at runtime: - **There is no database beside your app.** `new mysqli("localhost", "root", "", …)` — the XAMPP/WAMP default — has nothing to connect to. Use a managed MySQL or PostgreSQL database and read the credentials from environment variables (`faable deploy secrets set DB_HOST=…`). The build log warns when it spots a localhost connection in your sources. - **The filesystem is ephemeral.** Uploads and generated files live in the running container only: they are lost on restart, on sleep/wake and on every deploy, and two instances never see each other's files. Conventional write targets (`uploads/`, `storage/`, `writable/`, `var/`, `bootstrap/cache/`…) are made writable at start-up for caches and temporary work — put anything that must survive in object storage or a database. WordPress is refused at detection for exactly those two reasons, with an explanation instead of a broken site. ### Start Command By default the app is served by Apache from the detected document root. A `startCommand` in `faable.json` overrides it completely — use it for an app that starts another way, e.g.: ```json { "startCommand": "php artisan queue:work" } ``` --- ## 📄 Static Sites No `package.json`, no framework, no build step — just an `index.html` and the files beside it: ``` my-site/ ├── index.html ← triggers static detection ├── style.css ├── script.js └── assets/ ``` That deploys as it is. Your files are served by **nginx** on Faable's shared static runtime: no language runtime boots, nothing is installed, and the deploy is over in seconds. It is the same site you would open with `file://`, on a URL. This is the **last** buildpack detection tries, and deliberately so: an `index.html` is the weakest signal in the repository — every framework ships one. Any manifest (`package.json`, `requirements.txt`, `composer.json`) or a `Dockerfile` claims the repo first, so a React project keeps building as React and only a repo nothing else can build lands here. ### Document Root The builder serves the first of these that holds an `index.html`: | Layout | Served directory | | :---------------------------------- | :------------------ | | `public/index.html` | `public/` | | `dist/index.html` | `dist/` | | `build/index.html` | `build/` | | `_site/index.html` | `_site/` | | `index.html` in the repository root | the repository root | A build output directory wins over the repository root, so a repo carrying a committed build in `dist/` ships **only** that directory — the sources beside it never leave the builder. When the repository root is the document root there is nothing to separate, so the whole repository is published: every file becomes a URL, not just the pages you link to. The build log says so, and names any `.env`-style file it finds. Keep secrets out of a repo you deploy this way. `docs/` is **not** a document root: it is GitHub Pages' convention, but it is also where most repositories keep documentation for a project that is not a website. Point `rootDir` at it if that is what you want to publish. ### Single-Page Apps By default an unknown path returns **404** — correct for a multi-page site, wrong for a client-side router. Turn on the rewrite-to-`index.html` fallback in `faable.json`: ```json { "static": { "spa": true } } ``` You rarely need this: a React, Vue or Angular repo has a `package.json`, so it builds with the [Node.js buildpack](#static-frontends), which already knows whether its framework wants an SPA fallback. This switch is for a **built** SPA committed to the repository with no manifest beside it. ### What You Don't Get There is no server, so there is nothing to run server-side code with: no PHP, no API routes, no `$PORT` to listen on, no environment variables reaching your pages (a static file is served byte-for-byte, so a secret in your JavaScript is a public secret). Assets are cached hard for 30 days by their URL — hash your filenames if you need instant invalidation. If your site needs any of that, it needs a real runtime: add a `package.json`, a Python manifest, or a `Dockerfile`. --- ## 🐳 Dockerfile Projects > [!IMPORTANT] > Available on the **Hobby and Pro** plans. On Free, apps build with the managed Node, Python and PHP buildpacks — see [Docker and bring-your-own-image](pricing.md#docker-and-bring-your-own-image) for what that means for your repo. No `package.json`, no Python manifests, but a `Dockerfile`? The builder runs it **verbatim** with BuildKit on Faable's build infrastructure (targeting `linux/amd64`), pushes the image and pins the deploy to its digest — you control everything. Just honor the port contract below. If a `package.json` with a `next` dependency sits beside the Dockerfile (reachable by forcing `"buildpack": "docker"`), the deploy is registered as a Next.js app so the platform provisions its build cache. --- ## 🔌 The Port Contract Whatever the stack, your app must listen on **`0.0.0.0`** at the port given by the **`$PORT`** environment variable (the platform sets it to `80`): ```js app.listen(process.env.PORT, '0.0.0.0') ``` The platform also injects `FAABLE_HOST` (your app's public URL). Platform-managed names — `PORT`, `FAABLE_HOST`, `FAABLE_APP_ID`, `FAABLE_DEPLOY_ID`, `FAABLE_RELEASE`, `FAABLE_GIT_COMMIT`, `FAABLE_GIT_REF` — are reserved; secrets you define with those names are ignored. See [Environment & Releases](environment.mdx) for the full list of injected variables and how the release version is resolved. --- ## ⚙️ `faable.json` Reference `faable deploy link` creates this file at your project root to bind the repo to an app. All fields are optional: ```json { "app_id": "app_xxx", "app_slug": "my-app", "buildpack": "docker", "rootDir": "apps/web", "buildCommand": "npm run build:prod", "startCommand": "node dist/main.js", "next": { "standalone": false }, "static": { "spa": false } } ``` | Field | Purpose | | :-------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `app_id` / `app_slug` | Which Faable app this repo deploys to (written by `faable deploy link`). | | `buildpack` | Force a buildpack (`php`, `node`, `python`, `docker`, `static`) instead of auto-detection. `docker` requires Hobby or Pro. | | `rootDir` | Monorepo Root Directory — the subdirectory the app builds from. This is where you set it; a per-app Root Directory (support-configured, for several apps in one repo) takes precedence when present. | | `buildCommand` | Node: build step used when `package.json` has no `build` script (the `build` script wins otherwise). Python and PHP: replaces the manifest install. | | `startCommand` | Overrides everything — framework detection and `npm run start`. On a Next.js app it also opts out of the standalone output. | | `next.standalone` | Set `false` to ship the full workspace instead of Next's compact standalone output. | | `static.spa` | Static sites only: set `true` so unknown paths rewrite to `index.html` (client-side routers). Defaults to `false` — unknown paths 404. | --- ## ❓ FAQ ### My deployed app crashes with "module not found" — why? Usually the module isn't installed where the app runs: check that it's listed in `dependencies` (not only `devDependencies` if your start command needs it at runtime), and check the deploy logs for the install step. In a monorepo, make sure the app's Root Directory is set so workspace packages resolve. ### I have a Dockerfile but Faable ignores it — why? Because there's also a `package.json` (or Python manifest) at the root, which takes precedence. Force it with `"buildpack": "docker"` in `faable.json`, remove the manifest from the root, or embrace the zero-config Node/Python buildpacks. ### Can I deploy a PHP app that needs MySQL? Yes — but the database has to live somewhere else. Faable runs your PHP app, not a database beside it, so a `new mysqli("localhost", "root", "", "my_db")` copied from a XAMPP project connects to nothing. Point it at a managed MySQL or PostgreSQL provider and read the credentials from environment variables: ```php $db = new mysqli(getenv('DB_HOST'), getenv('DB_USER'), getenv('DB_PASSWORD'), getenv('DB_NAME')); ``` Then `faable deploy secrets set DB_HOST=… DB_USER=…`. The build log warns you when it finds a localhost connection in your sources, so you find out at deploy time rather than from a blank page. ### Can I deploy WordPress? Not yet, and the deploy says so instead of shipping a broken site: WordPress needs a MySQL database beside the app and a persistent filesystem for `wp-content/uploads`, and Faable gives an app neither. ### Can I deploy a plain HTML site with no framework and no package.json? Yes. A repository that is an `index.html` plus its CSS, JavaScript and images deploys as it is — the static buildpack picks it up and nginx serves the files, with no build step and no manifest to add. That covers a portfolio, a landing page, a CV, a course project, or a site built by hand or by an AI tool. If the site lives in a subdirectory, point `rootDir` at it. See [Static Sites](#-static-sites). ### Why is my static site 404ing on every route except the homepage? Because a static site serves files, and the file for that route does not exist. That is correct for a multi-page site and wrong for a single-page app with a client-side router — turn on the fallback with `{ "static": { "spa": true } }` in `faable.json` and unknown paths rewrite to `index.html`. ### My repo was made for another platform (Cerebrium, Replicate, Fly…) — will it deploy? Cerebrium projects deploy natively (see above). For other platforms, the detection error names the config file it recognized (`cog.yaml`, `fly.toml`, `render.yaml`, `vercel.json`, `netlify.toml`, `railway.json`, `heroku.yml`…) — Faable can't consume those directly; add one of the supported manifests, or a `Dockerfile` if you are on Hobby or Pro. ### My deploy says Docker builds "require a paid plan" — what now? That is a plan entitlement, not a build error: Docker and Dockerfile builds (and deploying a prebuilt image) are available on **Hobby and Pro**. Nothing is wrong with your repository and there is no build log to read — the platform declined the build instead of running it. On the Free plan, add the manifest for your stack at the repository root and the managed buildpack takes over: a `package.json` for Node.js, a `requirements.txt` or `pyproject.toml` for Python, a `composer.json` for PHP. If your repo already has one _and_ a Dockerfile, [detection prefers the manifest](#-how-detection-works), so it deploys on Free as-is. A PHP repository with no `composer.json` is the one case a manifest cannot rescue: plain `.php` files are a fallback signal and lose to a `Dockerfile`, so remove the `Dockerfile` or add a `composer.json`. Either way, drop any `"buildpack": "docker"` from `faable.json` — forcing it is subject to the same plan requirement. Otherwise, upgrading to Hobby builds your Dockerfile verbatim. Full comparison: [Docker and bring-your-own-image](pricing.md#docker-and-bring-your-own-image). ### Which Node version does my app run on? The version from `engines.node` in `package.json`, resolved to the latest release of that major (supported majors: 20, 22, 24). Without it, the builder uses its own current Node version, which changes as the platform upgrades — pin `engines.node` so your app's runtime is deterministic. ### How do I deploy a single app from a monorepo? Add a `faable.json` at the repository root declaring the subdirectory to build from: ```json { "rootDir": "apps/api" } ``` The app builds from that subdirectory. Dependencies install at the workspace root when the repository root has a `package.json` (so hoisted packages and shared workspace libraries resolve normally), and inside the subdirectory when it doesn't — a repo that is just `client/` + `server/` works without a workspace. Keep the `faable.json` at the repository root: it is the only one read. One Faable app per deployable package. See [Monorepos (Root Directory)](#monorepos-root-directory) for the details. ### What port should my app listen on? Read `$PORT` and bind `0.0.0.0`. Hardcoding `localhost` or another port is the most common cause of an unresponsive app. ### Does the build run on Faable's servers? Yes — `faable deploy` uploads your project sources and the build runs on Faable's build infrastructure; no Docker is needed on your machine. `faable deploy link` can scaffold a GitHub Actions workflow that deploys on every push to `main`. --- ## 🔗 Related - **[Get Started](get-started.md)** — link a repo and ship your first deploy. - **[Runtime](runtime.md)** — how your app runs: restarts, env vars, app manager. - **[GitHub Actions](github-actions.md)** — deploy from CI on every push. - **Framework guides** — complete end-to-end examples: [Express](guides/guide-express.md) (Node.js), [Django](guides/guide-django.md), [FastAPI](guides/guide-fastapi.md), [Flask](guides/guide-flask.md), [PHP & Laravel](guides/guide-php.md). --- ## Environment & Releases Source: https://faable.com/docs/deploy/environment # Environment & Releases 🏷️ Every deployment runs with a set of **platform-injected environment variables** on top of the [secrets you define](runtime.md). They identify the app, the deployment, and the release you shipped, and they're available at **both build time and runtime** — so a framework can read them while it compiles _and_ your process can read them while it serves. ## Platform-injected variables | Variable | Example | When | What it is | | :------------------ | :----------------- | :------------ | :------------------------------------------------------------------- | | `PORT` | `80` | Always | The port your app **must** listen on (bind `0.0.0.0`). | | `FAABLE_HOST` | `myapp.faable.app` | Always | Your app's public URL (host). | | `FAABLE_APP_ID` | `app_a1b2c3…` | Always | The app this deployment belongs to. | | `FAABLE_DEPLOY_ID` | `deployment_9f8e…` | Always | This deployment's unique id. Also used as the Next.js `buildId`. | | `FAABLE_RELEASE` | `1.4.2` | When resolved | The release version (see below). Absent when none could be resolved. | | `FAABLE_GIT_COMMIT` | `575bfa8…` (full) | When known | The full commit SHA the deployment was built from. | | `FAABLE_GIT_REF` | `refs/heads/main` | When known | The git ref (branch) of the deployment. | > [!NOTE] > `PORT` and `FAABLE_HOST` are the two you'll actually build against — the rest > are metadata for logging, self-reporting, and release tracking. `FAABLE_RELEASE` > and the `FAABLE_GIT_*` variables are only set when Faable could determine them > (see [Release versioning](#release-versioning)); code defensively and fall back > when they're missing. ### Reserved names All of the names above are **reserved**, plus `START_COMMAND` (how the runtime knows how to boot your app). If you define a [secret](runtime.md) with one of those names it is **ignored** — the platform value always wins — so your app can rely on them meaning what this page says. The CLI and the dashboard both flag a reserved name as you set it, so you don't have to come back to this page to find out why your `PORT=3000` did nothing. ### Runtime defaults These are **not** reserved — the runtime image sets them, and a secret of yours does override them. They are listed here because overriding them is usually a mistake: | Variable | Default | Set on | | :----------------- | :----------- | :-------------- | | `NODE_ENV` | `production` | Node runtimes | | `PYTHONUNBUFFERED` | `1` | Python runtimes | Setting `NODE_ENV=development` on a deployed app is the common one: frameworks switch to development behaviour (slower, more verbose, sometimes unsafe) even though the build was a production build. ## Release versioning A deployment is always identified by its **deployment id** and the **commit** it was built from. The **release** is an _extra, optional label_ your app can self-report (in a footer, a Sentry release, an analytics property, a request header). Faable never uses it to identify or order deployments — it just records it and injects it as `FAABLE_RELEASE`. When you deploy, the release is resolved in this order (first match wins): 1. The **`--release`** flag: `faable deploy --release 1.4.2` 2. The **`FAABLE_RELEASE`** environment variable at deploy time 3. The **latest git tag** reachable from `HEAD` (`git describe --tags`), with a leading `v` stripped — e.g. a `v1.4.2` tag becomes `1.4.2` 4. Otherwise it's **omitted**, and `FAABLE_RELEASE` is simply not injected Only tags that look like a version (`1.4.2`, `v1.4.2`) are used — a tag like `nightly` is ignored. The commit always travels separately as `FAABLE_GIT_COMMIT`, so no commit SHA is ever forced into `FAABLE_RELEASE`. > [!NOTE] > **Deploying locally?** `git describe` reads your **local** tags, so run > `git fetch --tags` first if you're not sure they're current. It picks the > latest tag _reachable from `HEAD`_ — locally there's no version bump, so if > `HEAD` is ahead of your last tag you'll get that last tag (the exact commit > still ships in `FAABLE_GIT_COMMIT`). In CI, a tool like `semantic-release` cuts > a fresh tag before the deploy, so each build gets its own version. Pass > `--release` any time you want to set it explicitly. ## Reading it at build time Runtime frameworks just read `process.env.FAABLE_RELEASE`. But some frameworks **inline** environment variables at **build time** — Next.js bakes `NEXT_PUBLIC_*` into the client bundle when it compiles. For those, read the platform variable in your build config and re-expose it: ```js // next.config.js module.exports = { env: { NEXT_PUBLIC_APP_VERSION: process.env.FAABLE_RELEASE || '0.0.0-dev', NEXT_PUBLIC_APP_COMMIT: process.env.FAABLE_GIT_COMMIT || '' } } ``` > [!IMPORTANT] > The platform injects these variables into the **build** and the **runtime**. > If your framework inlines env vars at build time (Next.js, Vite, and friends), > make sure you read `FAABLE_RELEASE` in your build config — reading it only at > runtime won't help a value that was already baked into the bundle. Now your app can show the release wherever it's useful: ```jsx
v{process.env.NEXT_PUBLIC_APP_VERSION}
``` ```js // Sentry — tag every event with the release you shipped Sentry.init({ release: process.env.NEXT_PUBLIC_APP_COMMIT }) ``` See [What the Builder Expects](build-requirements.md) for the port contract and [the CLI reference](../cli.md) for `faable deploy --release`. --- ## Deploy from your own CI (GitHub Actions) Source: https://faable.com/docs/deploy/github-actions # Deploy from your own CI **You don't need a GitHub Actions workflow to deploy on Faable.** When you link a repository to an app, push-to-deploy is enabled server-side: every push to your deploy branch builds and deploys automatically, with build feedback posted as a `faable/deploy` check on the commit. There is nothing to configure and no CI minutes are spent. This page is for teams that **want** to run the deploy from their own CI instead — for example to run custom steps before deploying, or to control exactly when a deploy happens. > [!TIP] > If all you want is "run my tests before deploying", you don't need your own > deploy workflow either: keep push-to-deploy and ask us to enable the > **wait-for-CI gate** for your app — the platform then deploys a commit only > after all your CI checks pass, like Heroku's CI integration. ## How it works The Faable CLI authenticates from GitHub Actions via **OIDC** and resolves the app from the linked repository, so you **don't need an `app_id`, an API key, or any secret**. > [!IMPORTANT] > Before your first deploy, **link the repository to your app** — once, from > the [dashboard](https://dashboard.faable.com) (**Link repository**) or by > running `faable deploy link` in your repo. If the repo isn't linked, `deploy` > fails with `Request failed with status code 404`. > > An app deploys through **one** trigger: if your repo carries its own deploy > workflow, make sure the app's push-to-deploy is not also active (linking a > repo that already contains a Faable workflow keeps the workflow as the > trigger automatically) — otherwise every push would deploy twice. ## Example workflow Create a file inside `.github/workflows` (for example `deploy.yaml`): ```yaml name: Deploy to Faable on: push: branches: - main permissions: id-token: write contents: write pull-requests: write issues: write jobs: deploy: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 # Your own steps go here: tests, lint, codegen… - run: npx @faable/faable@latest deploy ``` The same workflow works for every runtime — Node, Python, or Docker. `faable deploy` detects your project type and, for Node projects, installs dependencies automatically when `node_modules` is missing (`npm ci` with a lockfile, `npm install` otherwise; `yarn` and `pnpm` lockfiles are honored when the tool is available). ## Build script If your app has a `build` step (TypeScript, Next.js, …) it is handled automatically when a `build` script is present in `package.json`: ```json { "name": "app_name", "scripts": { "build": "" } } ``` If your build script has a different name, pass `--npm_build_command` to the CLI to specify it. ## Deploy multiple environments To test features or preview changes before releasing to production, deploy the same repository to multiple **Faable apps** with different configurations (`staging`, `beta`, `preprod`…): create one workflow file per environment and point each one to a different app with `faable deploy --app `. In a monorepo you can also filter which pushes deploy which app — for example with [`turbo-ignore`](https://turbo.build/repo/docs/reference/turbo-ignore): ```yaml - run: | if npx --yes turbo-ignore my-app; then echo "No changes affecting my-app — skipping deploy" else npx @faable/faable@latest deploy my-app fi ``` --- ## Deploy SDK Source: https://faable.com/docs/deploy/sdk # Deploy SDK [`@faable/deploy-sdk`](https://www.npmjs.com/package/@faable/deploy-sdk) is the Node.js client for the **Faable Deploy API** (`https://api.faable.com`). It gives you programmatic access to everything you manage in the dashboard — apps, deployments, domains, secrets, and traffic metrics — so you can script provisioning, wire Deploy into your own tooling, or build on top of the platform. > [!NOTE] > You don't need the SDK for normal deploys. Pushing to your release branch (or > running the [CLI](../cli.md)) authenticates via **OIDC** and needs no > credentials — see [Get Started](get-started.md) and [GitHub Actions](github-actions.md). > Reach for the SDK when you want to **manage** apps from your own code. ## Install ```bash npm install @faable/deploy-sdk ``` ## Authenticate The client is Octokit-style — you pass an **auth strategy** and its credentials, no environment magic. Two strategies are supported: **API key** (`authApikey`) — the simplest path for a backend or a script: ```ts import { DeployApi, authApikey } from '@faable/deploy-sdk' const api = DeployApi.create({ authStrategy: authApikey, auth: { apikey: process.env.FAABLE_APIKEY! } }) ``` **Client credentials** (`authClientCredentials`) — for an OAuth client with the right scopes on the Deploy API: ```ts import { DeployApi, authClientCredentials } from '@faable/deploy-sdk' const api = DeployApi.create({ authStrategy: authClientCredentials, auth: { client_id: process.env.FAABLE_CLIENT_ID!, client_secret: process.env.FAABLE_CLIENT_SECRET! } }) ``` The base URL defaults to `https://api.faable.com`; pass `baseURL` only to target another environment. ## List apps and deployments List endpoints return a **paginator**. Call `.all()` to fetch every page flattened into an array, or `.first()` for just the first page (`{ results, next }`): ```ts // Every app in your account const apps = await api.appList().all() // Filter with FaableQL, one page at a time const { results } = await api.appList({ query: 'status:READY' }).first() // Deployments for review const deployments = await api.deploymentList().all() ``` ## Work with a single app ```ts const app = await api.appGet(appId) // Trigger a deployment const deployment = await api.deploymentCreate({ app_id: appId }) const status = await api.deploymentGet(deployment.id) // Tail runtime logs const logs = await api.appLogs(appId, { limit: 200 }) // HTTP traffic metrics (defaults to the last 24h, 1h buckets) const traffic = await api.getAppTraffic(appId, { from: Math.floor(Date.now() / 1000) - 86_400, interval: 3_600 }) // Secrets (returns a paginator) const secrets = await api.listSecrets(appId).all() ``` ## Domains and projects ```ts const domains = await api.domainList().all() await api.domainCreate({ app_id: appId, fqdn: 'www.example.com', tls: true }) const projects = await api.projectList().all() ``` ## Reference Every dashboard resource has a matching set of methods on `DeployApi` (`appCreate`, `appUpdate`, `appDelete`, `appLinkRepository`, `domainUpdate`, `projectInvite`, …). They mirror the [Deploy API](get-started.md) one-to-one, are fully typed, and autocomplete in your editor. --- ## Web Application Firewall (WAF) Source: https://faable.com/docs/deploy/security-waf # Web Application Firewall **A Web Application Firewall (WAF) inspects incoming HTTP traffic and blocks malicious requests — such as SQL injection, cross-site scripting (XSS), and other OWASP Top 10 attacks — before they reach your app.** It sits between the internet and your service: every request must pass the WAF policy before Faable routes it to your application. Faable Deploy includes a managed WAF powered by the [OWASP Core Rule Set (CRS)](https://owasp.org/www-project-modsecurity-core-rule-set/), kept up to date by the platform. ## How Faable's WAF works Faable's WAF analyzes each HTTP request (method, headers, query string, body) against a **policy** — a collection of rules — and decides whether to allow, log, or block it. The default policy is the **OWASP Core Rule Set**, which protects against the OWASP Top 10: injection, XSS, path traversal, remote file inclusion, protocol violations, and more. Faable reviews and updates the rule set as new vulnerabilities and CRS releases appear, so your protection stays current without action from you. The WAF is **included with Faable Deploy on every plan** — there is nothing to buy and nothing to install. The policy is deployed alongside your Faable App in our European datacenters and starts inspecting traffic as soon as your app is live. ## Monitor mode vs block mode A WAF policy can run in two modes: - **Monitor (detection only):** suspicious requests are **logged but still served**. Use this when first enabling the WAF to observe what _would_ be blocked without affecting real users. - **Block (prevention):** matching requests are **logged and rejected** (typically `403 Forbidden`) before reaching your app. The recommended rollout is: enable in **monitor mode**, review the logs for a representative period (including peak traffic and any batch/integration jobs), tune any false positives, then switch to **block mode**. ## Reading WAF logs WAF events are available in the [Faable Dashboard](https://dashboard.faable.com). Each event records the matched rule(s), the request attributes that triggered it, the client IP, and the action taken (logged vs blocked). Use these logs to: - Confirm real attacks are being stopped. - Identify **false positives** — legitimate requests that match a rule (common with rich form payloads, file uploads, or API clients sending unusual headers). - Spot traffic patterns worth rate-limiting upstream. ## Tuning false positives The OWASP CRS is intentionally strict, so some legitimate traffic can match a rule. To reduce false positives without weakening protection: 1. **Run in monitor mode first** and collect a baseline of flagged requests. 2. **Identify the offending rule ID** from the WAF log for each false positive. 3. **Scope an exclusion narrowly** — for a specific path, parameter, or rule — rather than disabling whole rule categories. Request the exclusion with the rule IDs and endpoints it applies to. 4. **Re-test** the affected flows, then promote the policy to block mode. Prefer the **most specific exclusion possible**: disabling a broad category to fix one endpoint removes protection everywhere. ## Troubleshooting - **Legitimate requests return `403`:** the WAF is blocking a false positive. Find the matched rule ID in the logs and request a scoped exclusion (see above), or temporarily switch to monitor mode while you tune. - **Attacks not appearing in logs:** confirm the WAF policy is enabled on the correct app and that traffic is reaching Faable (not cached or served elsewhere). - **API clients failing after enabling block mode:** machine clients often send payloads that trip generic rules; capture the rule IDs and add path/parameter-scoped exclusions for those endpoints. ## Related - [Get Started with Faable Deploy](get-started.md) - [Custom domains](domains/custom-domain.md) · [SSL certificates](domains/ssl-certificates.md) - [Faable Deploy vs Vercel, Render & Railway](compare.mdx) — the WAF is included, not an add-on # Faable Auth --- ## Get Started Source: https://faable.com/docs/auth/get-started # Get Started with Faable Auth Faable Auth is a multi-tenant identity platform built around the OAuth 2.0 and OpenID Connect standards. You get social login, passwordless, RBAC, teams, custom domains and audit logs out of the box — without writing or maintaining any of the protocol plumbing yourself. > New to the concept? Read [What is a multi-tenant identity server?](what-is-a-multi-tenant-identity-server.mdx). Comparing platforms? See [Faable Auth vs Auth0, Clerk & Keycloak](compare.mdx). ## How Faable Auth is structured Four concepts are enough to model the whole product: - **Account** — your auth tenant. Hosted at `https://.auth.faable.link` or under your [custom domain](custom-domain.md). - **Connections** — sources of users: database (email + password), social (Google, GitHub, Microsoft, or any OAuth 2.0 provider), passwordless. - **Clients** — the applications that drive an OAuth flow against your account. - **Users & Teams** — identities created via your connections, grouped into teams with RBAC. ## Prerequisites 1. Create an account on the **[Faable Dashboard](https://dashboard.faable.com)**. 2. Create an **Auth Account** (your tenant). Note the auth domain shown in the dashboard. 3. Create a **Client** for your application. Save the **Client ID** and configure the **Allowed Callback URLs** (e.g. `http://localhost:3000/callback` for local dev). 4. Enable at least one **[Connection](connections.md)** — a social provider, passwordless, or database. ## Your first sign-in Pick the path that matches your stack. ### Path A — use a Quickstart The fastest way to add login to an existing app. - **[React Quickstart](quickstart/react.md)** — Vite SPA + session hooks from `@faable/auth-helpers-react`. - **[Next.js Quickstart](quickstart/nextjs.md)** — App Router + client SDK with PKCE. - **[React Native Quickstart](quickstart/react-native.md)** — Expo + Faable Auth helpers. ### Path B — roll your own using the SDK Build directly on `@faable/auth-js` for a custom integration: ```ts import { createClient } from '@faable/auth-js' const auth = createClient({ domain: 'your-tenant.auth.faable.link', clientId: '' }) await auth.signInWithOauthConnection({ redirectTo: 'https://app.example.com/callback' }) ``` The SDK handles the PKCE handshake, the redirect, and the token exchange. See the [Authorization Code Flow](oauth-flows/authorization-code.mdx) for what happens under the hood. ## What you can build Once a user can sign in, Faable Auth gives you the building blocks for the rest of your identity experience: | Feature | What it gives you | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | **[Change Email](change-email.md)** | Self-service email update with ownership verification (and optional double-confirmation). | | **[Team Invitations](team-invitations.md)** | Invite users by email; existing users are added directly, unknowns get a magic link that creates their account on click. | | **[Passwordless](passwordless.md)** | Magic link or OTP login, no password to remember. | | **[Actions](extensibility/actions.md)** | Run JavaScript in the auth flow to enforce rules or redirect through custom UI. | | **[Webhooks](extensibility/webhooks.md)** | Signed HTTPS callbacks for `user.created`, `user.updated`, `user.deleted`, `auth.login`. | | **[APIs](apis.md)** | Register backend resource servers and define scoped permissions. | | **[Logs](logs.md)** | Audit email deliveries, webhook calls, and authentication events. | | **[Custom Domain](custom-domain.md)** | Serve the login UI under your own branded domain with auto-renewed SSL. | | **[OIDC Logout](oidc/logout.md)** | RP-Initiated + Front-Channel logout across every signed-in application. | | **[UserInfo](oidc/userinfo.md)** | Standard claims endpoint, scope-gated per OIDC §5.4. | ## SDKs and libraries - **[`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js)** — client SDK for browsers and React Native (PKCE, session management, token refresh). - **[`@faable/auth-sdk`](https://www.npmjs.com/package/@faable/auth-sdk)** — server-side SDK for Node.js (token verification, admin operations). - **[`@faable/auth-helpers-react`](https://www.npmjs.com/package/@faable/auth-helpers-react)** — React hooks for session and user state. ## Pricing & limits See **[Auth pricing](pricing.md)** for MAU allowances and per-feature gating, and the [unified platform pricing](../platform/pricing.md) for plans and support tiers. --- ## Quickstarts Source: https://faable.com/docs/auth/quickstart # Quickstarts Pick your stack and wire up login, sessions and protected routes. Every frontend quickstart drives the same [Authorization Code flow with PKCE](/auth/oauth-flows/authorization-code) through [`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js) — the differences are only in how each framework handles reactivity and routing. ## Frontend | Framework | Use it when | | ------------------------------------------------------------ | ------------------------------------------------------------------------ | | [React (SPA)](/auth/quickstart/react) | A client-side React app (Vite, CRA) with no server of its own. | | [Next.js](/auth/quickstart/nextjs) | App Router with client components driving the session. | | [Next.js (Server-Side)](/auth/quickstart/nextjs-server-side) | You need the session in Server Components, Route Handlers or middleware. | | [Vue](/auth/quickstart/vue) | A Vue 3 SPA. | | [Nuxt](/auth/quickstart/nuxt) | Nuxt 3, with the session available on the server side too. | | [SvelteKit](/auth/quickstart/sveltekit) | SvelteKit, load functions included. | | [Angular](/auth/quickstart/angular) | Angular with a route guard and an HTTP interceptor. | | [JavaScript (Vanilla)](/auth/quickstart/javascript) | No framework — the framework-agnostic core pattern the others build on. | | [React Native](/auth/quickstart/react-native) | Mobile, using the system browser and a deep-link callback. | ## Backend | Stack | Use it when | | -------------------------------------------- | ----------------------------------------------------------------------------------------------- | | [FastAPI (Python)](/auth/quickstart/fastapi) | Your API receives a `Bearer` token and must verify it — signature, issuer, audience and scopes. | For Node.js the equivalent walkthrough lives in [Validate Access Tokens](/auth/validate-access-tokens), with an Express middleware. Any language works: the four checks are the same, and every stack has a JWKS-aware JWT library. ## Before you start Whichever you pick, you need a [Client](/auth/clients) registered in the [Faable Dashboard](https://dashboard.faable.com) with: - **Allowed Callback URLs** — where Faable redirects after login (e.g. `http://localhost:5173/callback`). - **Allowed Logout URLs** — where users land after signing out. - **Allowed Web Origins** — the origins allowed to call the token endpoint from a browser. Plus your **auth domain** (, or your [custom domain](/auth/custom-domain)) and the **Client ID**. Frontend clients are public — no client secret is involved. ## Related - **[Get Started](/auth/get-started)** — the concepts behind the code: accounts, connections, clients, users. - **[Authorization Code Flow](/auth/oauth-flows/authorization-code)** — what these SDKs do under the hood. - **[Social Login](/auth/social)** — let users sign in with Google, GitHub or Microsoft. --- ## What is a multi-tenant identity server? Source: https://faable.com/docs/auth/what-is-a-multi-tenant-identity-server # What is a multi-tenant identity server? **A multi-tenant identity server is a single authentication and authorization system that serves many separate applications or organizations — called tenants — while keeping each tenant's users, settings, and data fully isolated from the others.** One deployment, many independent identity boundaries. ## The terms, briefly - **Identity server** — the system that authenticates users (verifies who they are) and authorizes them (decides what they can access), typically using the OAuth 2.0 and OpenID Connect standards. It issues the tokens your apps trust. - **Tenant** — a self-contained boundary: one customer, organization, or product. Each tenant has its own users, connections, and configuration. - **Multi-tenant** — the same identity server hosts many tenants at once, isolating them from each other instead of running a separate server per tenant. ## Single-tenant vs multi-tenant | | Single-tenant | Multi-tenant | | ---------------------- | --------------------------- | ------------------------- | | Tenants per deployment | One | Many, isolated | | New customer/org | Stand up new infrastructure | Create a tenant — instant | | Operational cost | Grows with each tenant | Shared, scales centrally | | Typical use | One internal app | SaaS, B2B, many products | Multi-tenancy is what lets a SaaS business onboard a new customer organization without provisioning new infrastructure, and lets a team run several products from one identity system. ## What "isolated" actually means A tenant boundary is only useful if nothing crosses it by accident. In a well-designed multi-tenant identity server: - **Users are scoped to the tenant.** The same email address can exist in two tenants as two unrelated identities. A user of tenant A cannot log in to tenant B. - **Configuration is per tenant.** Login methods, social providers, password rules, branding, MFA policy and the list of applications are all set independently. - **Tokens are bound to the tenant.** Each tenant signs with its own keys and publishes its own JWKS. An access token carries the tenant as its issuer (`iss`), and an API validating tokens for tenant A will reject a token minted by tenant B — the signature won't verify and the issuer won't match. - **The tenant is chosen before any request is processed.** Typically from the hostname: `acme.auth.example` is one tenant, `globex.auth.example` another. No request parameter can switch tenants mid-flow. ## Tenant vs. your customers' organizations The word "tenant" causes most of the confusion, because SaaS products also call their customers tenants. There are two valid ways to model a B2B product, and the identity server should support both: | Model | How it looks | Choose it when | | ---------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | **One tenant, many organizations** | Your product is one tenant; each customer is a **team** inside it | Most SaaS: one login page, one user pool, users can belong to several customers | | **One tenant per customer** | Each customer gets its own tenant, domain, user pool and login methods | White-label or regulated deployments that need hard isolation per customer | The first model is the common one: a single identity boundary for the product, with membership and roles handled by teams. The second is what "multi-tenant" strictly means at the identity-server level, and it's the one you reach for when a customer must never share a user directory, a login page or a signing key with anyone else. ## How Faable Auth implements multi-tenancy [Faable Auth](get-started.md) is multi-tenant by design. Four concepts model the whole product: - **Account** — your tenant, hosted at `https://.auth.faable.link` or your own [custom domain](custom-domain.md). Each account has its own signing keys, published at `/.well-known/jwks.json`, and its own OpenID discovery document. - **Connections** — the sources of users for that tenant: database (email + password), social, passwordless, or generic OIDC. - **Clients** — the applications that run OAuth flows against the tenant, each with its own callback URLs and enabled connections. - **Users & Teams** — identities grouped into teams with role-based access control (RBAC). Teams are how you model your customers' organizations inside one tenant; [invitations](team-invitations.md) add members by email. Tenants are resolved **by host**: the request's hostname picks the account, and everything after that — users, keys, clients, [APIs](apis.md), [Actions](extensibility/actions.md), [Webhooks](extensibility/webhooks.md), the [login flow](login-flows.md) — belongs to that account only. Creating another tenant is a dashboard action, not a deployment. All of it is hosted 100% in Europe under GDPR. ## FAQ ### Is a multi-tenant identity server less secure than one server per tenant? No, provided isolation is enforced at every layer — separate user pools, separate signing keys, tenant resolved from the host. The security model is the same as a single-tenant server; what changes is that you don't operate one deployment per customer. Where you need a different _trust_ boundary (a customer who must not share a login page or key material with anyone), give that customer its own tenant. ### Do each of my customers' organizations need their own tenant? Usually not. Most SaaS products run as **one tenant** and model customer organizations as **teams** inside it, so a user can belong to several customers and there's one login page. Reserve one-tenant-per-customer for white-label or strictly regulated cases. ### Can each tenant have its own domain and branding? Yes. A tenant lives at `.auth.faable.link` by default and can use a [custom domain](custom-domain.md) such as `login.yourapp.com`; the [login experience](login-experience.md) — logo, colours, wording — is configured per tenant. ### Can the same person exist in two tenants? Yes, as two independent identities. Users are scoped to their tenant, so `alice@example.com` in tenant A and `alice@example.com` in tenant B are unrelated accounts with separate passwords, sessions and tokens. ### How does an API know which tenant a token belongs to? From the token's `iss` claim and its signature. Each tenant signs with its own keys, so an API configured for one tenant's issuer and JWKS rejects tokens from any other tenant automatically. See [Validate Access Tokens](validate-access-tokens.md). ### What's the difference between multi-tenancy and single sign-on? Single sign-on lets a user log in once and reach several **applications** inside the same tenant. Multi-tenancy lets one identity server host several **tenants** that don't share users at all. A multi-tenant server gives you SSO within each tenant, never across them. ### Which standards does a multi-tenant identity server use? OAuth 2.0 for authorization and OpenID Connect for authentication, the same as a single-tenant one. Each tenant exposes its own discovery document (`/.well-known/openid-configuration`) and key set, so standard libraries work unchanged — you point them at the tenant's URL. ## Related - [Get Started with Faable Auth](get-started.md) - [Academy — Tenant Building Blocks](academy/02-tenant-building-blocks.md) — accounts, clients and connections in depth - [Faable Auth vs Auth0, Clerk & Keycloak](compare.mdx) - [Connections](connections.md) · [Clients](clients.md) · [Team Invitations](team-invitations.md) · [Custom Domain](custom-domain.md) --- ## React Quickstart — Add Login to a Vite SPA Source: https://faable.com/docs/auth/quickstart/react # React Quickstart ⚛️ Add a complete login experience to a React single-page app: sign in with any [connection](../connections.md) you've enabled (Google, GitHub, email/password, passwordless), read the session with hooks, call your API with the access token, and sign out. The SDK drives the [Authorization Code flow with PKCE](../oauth-flows/authorization-code.md) and refreshes tokens automatically — no protocol code in your app. You'll use two packages: - **[`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js)** — the auth client (PKCE, session storage, auto-refresh, multi-tab sync). - **[`@faable/auth-helpers-react`](https://www.npmjs.com/package/@faable/auth-helpers-react)** — React provider and hooks on top of it. --- ## ✅ Prerequisites In the [Faable Dashboard](https://dashboard.faable.com), create a **Client** for your SPA and configure: - **Allowed Callback URLs:** `http://localhost:5173/callback` (add your production URL later). - **Allowed Logout URLs:** `http://localhost:5173`. - **Allowed Web Origins:** `http://localhost:5173`. Note your **auth domain** (`your-domain.auth.faable.link`) and **Client ID**. SPAs are public clients — no client secret is involved. --- ## 🛠️ Step 1: Create the App and Install ```bash npm create vite@latest my-app -- --template react-ts cd my-app npm install @faable/auth-js @faable/auth-helpers-react ``` ## Step 2: Create the Auth Client ```ts // src/auth.ts import { createClient } from '@faable/auth-js' export const auth = createClient({ domain: 'your-domain.auth.faable.link', clientId: 'YOUR_CLIENT_ID', redirectUri: window.location.origin + '/callback' }) ``` The client initializes itself on creation: it recovers an existing session from storage, or — on the callback URL — exchanges the PKCE `?code=` for tokens. ## Step 3: Wrap Your App with the Session Provider ```tsx // src/main.tsx import { SessionContextProvider } from '@faable/auth-helpers-react' import { createRoot } from 'react-dom/client' import App from './App' import { auth } from './auth' createRoot(document.getElementById('root')!).render( ) ``` The provider waits for initialization, exposes the session, and keeps it updated on login, token refresh, and logout — across browser tabs. ## Step 4: Login, User, and Logout ```tsx // src/App.tsx import { useSessionContext, useUser } from '@faable/auth-helpers-react' import Callback from './Callback' import { auth } from './auth' export default function App() { const { isLoading, session, error } = useSessionContext() const user = useUser() // The /callback route completes the login (Step 5) if (window.location.pathname === '/callback') return if (isLoading) return

Loading…

if (error) return

Auth error: {error.message}

if (!session) { return ( ) } return (

Hello {user?.email}

) } ``` - `signInWithOauthConnection({})` sends the user to your tenant's Universal Login with every connection you've enabled. Target one directly with `{ connection_id: "connection_..." }`. - `signOut()` clears the local session **and** the SSO cookie on the auth server. The `returnTo` URL must be in **Allowed Logout URLs**. - Both methods redirect the browser on success — the promise intentionally never resolves, so don't put "re-enable button" code after the `await`. ## Step 5: The Callback Route ```tsx // src/Callback.tsx import { useEffect, useState } from 'react' import { auth } from './auth' export default function Callback() { const [message, setMessage] = useState('Signing you in…') useEffect(() => { auth.handleRedirectCallback().then(({ error, returnTo }) => { if (error) setMessage(error.message) else window.location.replace(returnTo ?? '/') }) }, []) return

{message}

} ``` `handleRedirectCallback()` awaits the code-for-tokens exchange (it's idempotent — the client already started it) and hands you `returnTo` if you passed one to `signInWithOauthConnection({ returnTo })`, so deep links survive the login round-trip. ## Step 6: Call Your API The access token lives on the session. Read it fresh before each call — `getSession()` auto-refreshes an expired session: ```ts import { auth } from './auth' export async function apiFetch(path: string) { const { data, error } = await auth.getSession() if (error || !data.session) throw new Error('Not signed in') return fetch(`https://api.myapp.com${path}`, { headers: { authorization: `Bearer ${data.session.access_token}` } }) } ``` If your backend validates the token's `audience`, pass your [API](../apis.md) identifier when creating the client (`createClient({ ..., audience: "https://api.myapp.com" })`) — and see [Validate Access Tokens](../validate-access-tokens.md) for the Express middleware on the other side. --- ## ❓ FAQ ### How do I get the access token? From the session: `const { data } = await auth.getSession()`, then `data.session?.access_token`. There is no separate `getAccessToken()` method — `getSession()` already refreshes expired tokens before returning. ### Do I need to handle token refresh? No. The SDK refreshes sessions automatically in the background using the [Refresh Token flow](../oauth-flows/refresh-token.md), and syncs the result across tabs. ### How do users pick a login method? By default they choose on the Universal Login screen among the [connections](../connections.md) enabled for your client. To skip the screen and go straight to one provider, pass `connection_id` to `signInWithOauthConnection`. ### Why does my logout return a 400? The `returnTo` URL must be registered in the client's **Allowed Logout URLs** in the dashboard — same rule as callback URLs for login. ### Can errors throw somewhere unexpected? No — every SDK method resolves `{ data, error }` and never throws for expected failures. Only `createClient` itself throws, when `domain` or `clientId` is missing. --- ## 🔗 Related - **[Next.js Quickstart](nextjs.md)** — the same login for Next.js apps. - **[React Native Quickstart](react-native.md)** — Expo / mobile. - **[Authorization Code Flow](../oauth-flows/authorization-code.md)** — what the SDK does under the hood. - **[Validate Access Tokens](../validate-access-tokens.md)** — verify these tokens in your backend. - **[Connections](../connections.md)** — enable Google, GitHub, passwordless, and more. --- ## Next.js Authentication — Add Login with Faable Auth (App & Pages Router) Source: https://faable.com/docs/auth/quickstart/nextjs import { Tabs } from 'nextra/components' # Next.js Add Faable Auth to your Next.js application to easily authenticate your users. This guide shows you how to integrate the Faable Auth login flow using the secure **Authorization Code Flow with PKCE**, meaning the token exchange happens automatically and securely on the client side. The examples below cover both the **App Router** (`app/`, Next.js 13+) and the **Pages Router** (`pages/`). Pick the tab that matches your project. ## Prerequisites Before you start, you need to prepare your Faable Auth environment: 1. **Faable Auth Domain.** In the [Faable Dashboard](https://dashboard.faable.com), go to **Auth → (your account) → Settings → Auth Configuration → Domain**. That value (e.g. `your-tenant.auth.faable.link`) is what you'll pass as `domain` below. > The **Custom Domains** section on that same Settings page is optional — it lets you serve the hosted login under your own brand (e.g. `auth.yourcompany.com`). You do **not** need to create one to follow this guide; the default `*.auth.faable.link` works out of the box. See [Custom Domain](../custom-domain.md) if you want to set one up later. 2. **Client.** In the dashboard, create a new [Client](../clients.md) for your application and note its **Client ID**. Configure the **Allowed Callback URLs** to include your callback (e.g. `http://localhost:3000/callback` for local development). ## Installation Install the required Faable packages in your Next.js project: ```bash npm install @faable/auth-js @faable/auth-helpers-react ``` ## Initialize the Faable Auth client Create a shared client instance you can import from anywhere in your app. ```ts // lib/faable.ts import { createClient } from '@faable/auth-js' export const faableauth = createClient({ domain: 'your-tenant.auth.faable.link', // from Dashboard → Auth → Settings → Domain clientId: '' }) ``` ## Add the session provider The `SessionContextProvider` from `@faable/auth-helpers-react` exposes the current session to your components. In the App Router it must live inside a `"use client"` boundary; in the Pages Router it goes in `_app.tsx`. Create a small client component that owns the provider: ```tsx // app/providers/ClientSide.tsx 'use client' import { SessionContextProvider } from '@faable/auth-helpers-react' import { faableauth } from '../../lib/faable' export function ClientSide({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` Then wrap your root layout with it. The layout itself stays a server component — only `ClientSide` is client-side. ```tsx // app/layout.tsx import { ClientSide } from './providers/ClientSide' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` Wrap your app in `pages/_app.tsx`: ```tsx // pages/_app.tsx import type { AppProps } from 'next/app' import { SessionContextProvider } from '@faable/auth-helpers-react' import { faableauth } from '../lib/faable' export default function App({ Component, pageProps }: AppProps) { return ( ) } ``` ## Add the callback route After login the user is redirected to the callback URL you registered on your Client. `@faable/auth-js` reads the tokens from the URL automatically, but your callback route should **await** that processing so it can: - redirect **only once** the token exchange has finished, and - show an error instead of getting stuck on "Signing you in…" if the exchange fails (e.g. an expired login attempt). Call `handleRedirectCallback()` for exactly that. It returns `{ error, returnTo }`: on success send the user onward (to `returnTo` if you set one when starting the login — see below), and on failure surface `error.message`. ```tsx // app/callback/page.tsx 'use client' import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { faableauth } from '../../lib/faable' export default function CallbackPage() { const router = useRouter() const [error, setError] = useState(null) useEffect(() => { faableauth.handleRedirectCallback().then(({ error, returnTo }) => { if (error) setError(error.message) else router.replace(returnTo ?? '/') }) }, [router]) if (error) return

Sign-in failed: {error}

return

Signing you in…

} ```
```tsx // pages/callback.tsx import { useEffect, useState } from 'react' import { useRouter } from 'next/router' import { faableauth } from '../lib/faable' export default function CallbackPage() { const router = useRouter() const [error, setError] = useState(null) useEffect(() => { faableauth.handleRedirectCallback().then(({ error, returnTo }) => { if (error) setError(error.message) else router.replace(returnTo ?? '/') }) }, [router]) if (error) return

Sign-in failed: {error}

return

Signing you in…

} ```
> **Returning the user to where they were.** Pass `returnTo` when you start the > login and it round-trips back to you here — no `sessionStorage` bookkeeping > needed. It is stored locally next to the PKCE verifier and never sent to the > server: > > ```ts > await faableauth.signInWithOauthConnection({ > redirectTo: window.location.origin + '/callback', > returnTo: '/dashboard' > }) > ``` ## Accessing user state Anywhere inside the provider, use the `useSession` and `useUser` hooks to read the current session and user profile. Call `signInWithOauthConnection` on the client to start the login, and `signOut` to clear it. ```tsx // app/page.tsx (or pages/index.tsx) 'use client' import { useSession, useUser } from '@faable/auth-helpers-react' import { faableauth } from '../lib/faable' export default function Home() { const session = useSession() const user = useUser() const handleLogin = async () => { await faableauth.signInWithOauthConnection({ redirectTo: window.location.origin + '/callback' }) } const handleLogout = async () => { await faableauth.signOut() } if (!session) { return (

You are not logged in.

) } return (

Welcome, {user?.email}

Your Access Token: {session.access_token}

) } ``` > By default, `signInWithOauthConnection` opens your hosted login page and the user picks which connection to use. To skip that picker and force a specific provider (e.g. Google), pass `connection_id: "connection_xxxxxxxxxxxxxxxxxxxxxx"` — you'll find the ID in **Dashboard → Auth → Connections**. When `handleLogin` runs, `@faable/auth-js` takes care of redirecting the user, generating the PKCE verifier, and exchanging the code for tokens when the user is sent back to your callback URL. ## Custom username + password login If you want to build your own login form instead of using the hosted login page, use `signInWithUsernamePassword`. This is useful when you have a **Database Connection** configured on your tenant and want full control over the UI. Make sure you have a database connection enabled on your Client (**Dashboard → Auth → Database**). The credentials are submitted directly to Faable Auth's `/usernamepassword/login` endpoint, which on success auto-submits a form that completes the OAuth handshake against your callback URL — same end state as `signInWithOauthConnection`. ```tsx // app/login/page.tsx (or pages/login.tsx) 'use client' import { useState } from 'react' import { faableauth } from '../../lib/faable' export default function LoginPage() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState(null) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError(null) const { error } = await faableauth.signInWithUsernamePassword({ username: email, password, redirect_uri: window.location.origin + '/callback' }) if (error) setError(error.message) } return (
setEmail(e.target.value)} placeholder="Email" required /> setPassword(e.target.value)} placeholder="Password" required /> {error &&

{error}

}
) } ``` Parameters: | Field | Required | Description | | -------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | | `username` | Yes | The user's email (or username, depending on your Database Connection's identifier settings). | | `password` | Yes | The user's password. Submitted over TLS to Faable Auth — never store it client-side. | | `redirect_uri` | No | Overrides the `redirect_uri` configured on the client. Must be in your Client's **Allowed Callback URLs** whitelist. | | `state` | No | Opaque value round-tripped through the OAuth flow. Useful for CSRF protection or preserving navigation intent. | On success the user lands on your callback URL with an authorization code that `@faable/auth-js` exchanges for a session — exactly like the hosted-login flow. On failure, the returned `error` carries the server message (wrong credentials, blocked user, etc.). ## FAQ ### Does this work with both the App Router and the Pages Router? Yes — the examples above cover both. The only difference is where the `SessionContextProvider` lives: a `"use client"` component wrapping the root layout in the App Router, or `pages/_app.tsx` in the Pages Router. ### Where does the token exchange happen — server or client? Client-side. `@faable/auth-js` runs the [Authorization Code flow with PKCE](../oauth-flows/authorization-code.md) in the browser and stores the session; no secret ships to the server. To gate Next.js pages, Route Handlers, or middleware on the server — so protected content never reaches the browser without a session — read the session cookie with `getSessionFromCookies`; see [Next.js (Server-Side)](nextjs-server-side.md). To verify a `Bearer` access token your **API** receives, see [Validate Access Tokens](../validate-access-tokens.md). ### How do I send users straight to one provider? Pass `connection_id` to `signInWithOauthConnection` (e.g. `{ connection_id: "connection_..." }`). Find the ID in **Dashboard → Auth → Connections**. Without it, users pick on the Universal Login screen. ### Do I need to handle token refresh? No. The SDK refreshes the session automatically in the background using the [Refresh Token flow](../oauth-flows/refresh-token.md) and syncs the result across tabs. ## Related - **[React Quickstart](react.md)** — the same login for a Vite SPA. - **[Vue](vue.md)** · **[Nuxt](nuxt.md)** · **[SvelteKit](sveltekit.md)** · **[Angular](angular.md)** · **[JavaScript (vanilla)](javascript.md)** — the same client in other frameworks. - **[Next.js (Server-Side)](nextjs-server-side.md)** — protect routes in middleware and Server Components with `getSessionFromCookies`. - **[Authorization Code Flow with PKCE](../oauth-flows/authorization-code.md)** — what the SDK does under the hood. - **[Validate Access Tokens](../validate-access-tokens.md)** — verify these tokens in your backend. - **[Connections](../connections.md)** — enable Google, GitHub, passwordless, and more. --- ## Next.js Server-Side Auth — Protect Routes in App Router & Middleware Source: https://faable.com/docs/auth/quickstart/nextjs-server-side import { Tabs } from 'nextra/components' # Next.js — Protect Routes on the Server The [Next.js Quickstart](nextjs.md) runs the login flow in the browser: the SDK stores the session and React hooks (`useSession`, `useUser`) read it client-side. That's enough for most apps, but it can't stop protected content from being **rendered and shipped to the browser** before the client checks the session. When you need true server-side gating — a docs site that must require login, a dashboard that shouldn't stream HTML to anonymous users, an API route that reads the caller's session — read the session on the server with `getSessionFromCookies`. > **This is different from [Validate Access Tokens](../validate-access-tokens.md).** > That guide verifies a `Bearer` access token your **API** receives (RS256 + > JWKS). This guide reads the browser **session cookie** to gate Next.js > routes. Use token validation for machine-to-machine / API calls; use > `getSessionFromCookies` for App Router pages, Route Handlers, and middleware. ## Prerequisites Follow the [Next.js Quickstart](nextjs.md) first. The one change required for server-side reads: the client **must use cookie storage** so the session lives in a cookie the server can see on every request. ```ts // lib/faable.ts import { createClient } from '@faable/auth-js' export const faableauth = createClient({ domain: 'your-tenant.auth.faable.link', clientId: '', storage: 'cookie' // required for SSR — the server reads this cookie }) ``` The default `localStorage` storage is invisible to the server, so `getSessionFromCookies` would always return `null`. ## `getSessionFromCookies` ```ts import { getSessionFromCookies } from '@faable/auth-js' const session = await getSessionFromCookies(cookieStore, { clientId: '' // storageKey: '' // only if you set a custom storageKey in createClient }) ``` - **Async** — always `await` it. - Returns the full `Session` (`access_token`, `refresh_token`, `expires_at`, `user`) or `null` when there's no valid session cookie. - `cookieStore` accepts the `cookies()` object from `next/headers`, a `NextRequest.cookies` object (for middleware), or a plain `{ name: value }` map. - Pass the **same `clientId`** you used in `createClient`. If you set a custom `storageKey`, mirror it here so the helper looks at the same cookie. ## Gate routes in `middleware.ts` (recommended) Middleware runs at the Edge before your page renders, so an anonymous request never receives protected HTML. Pass `req.cookies` directly: ```ts // middleware.ts import { NextRequest, NextResponse } from 'next/server' import { getSessionFromCookies } from '@faable/auth-js' export async function middleware(req: NextRequest) { const session = await getSessionFromCookies(req.cookies, { clientId: '' }) if (!session) { const loginUrl = new URL('/login', req.url) // Optional: remember where the user was headed. Pass returnTo to the SDK // when you start the login — do NOT append it to the OAuth redirect_uri. loginUrl.searchParams.set('returnTo', req.nextUrl.pathname) return NextResponse.redirect(loginUrl) } return NextResponse.next() } // Protect everything except the login page and Next.js internals. export const config = { matcher: ['/((?!login|_next|favicon.ico).*)'] } ``` ## Read the session in a Server Component or Route Handler Use `cookies()` from `next/headers`. In **Next.js 15+ `cookies()` is async**, so `await` it before passing it in: ```tsx // app/dashboard/page.tsx import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSessionFromCookies } from '@faable/auth-js' export default async function DashboardPage() { const session = await getSessionFromCookies(await cookies(), { clientId: '' }) if (!session) redirect('/login') return

Welcome, {session.user.email}

} ```
```tsx // app/dashboard/page.tsx import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSessionFromCookies } from '@faable/auth-js' export default async function DashboardPage() { // Next.js 14: cookies() is synchronous — no inner await. const session = await getSessionFromCookies(cookies(), { clientId: '' }) if (!session) redirect('/login') return

Welcome, {session.user.email}

} ```
A Route Handler is the same — read `cookies()` and branch on the result: ```ts // app/api/me/route.ts import { cookies } from 'next/headers' import { NextResponse } from 'next/server' import { getSessionFromCookies } from '@faable/auth-js' export async function GET() { const session = await getSessionFromCookies(await cookies(), { clientId: '' }) if (!session) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) return NextResponse.json({ user: session.user }) } ``` ## Security & gotchas > **The session cookie is not `HttpOnly`.** `@faable/auth-js` writes it from > JavaScript, so it can't be marked `HttpOnly` — an XSS on your origin can read > the `access_token`. Treat XSS prevention (CSP, output escaping, framework > guarantees) as a hard requirement, and use `Secure` + `SameSite` cookies. - **The cookie may be chunked.** When the session is large it's split across `faableauth-.0`, `.1`, … `getSessionFromCookies` reassembles the chunks automatically, but any other reader (a separate backend, an edge worker) must rejoin `.0`, `.1`, … in order before parsing. - **Don't put `returnTo` inside `redirectTo`.** If you set `redirectTo: '/callback?returnTo=/x'`, the authorization `code` gets appended to a URL that already has a query and the login can fail. Pass `returnTo` as its own SDK option (`signInWith…({ returnTo: '/x' })`) — the SDK stores it locally next to the PKCE verifier and round-trips it back to you — and keep `redirectTo` a clean URL. ## FAQ ### Do I still need the client-side provider? Only if you use the React hooks (`useSession`, `useUser`) or trigger login from components. Server-side gating with `getSessionFromCookies` works on its own, but most apps use both: middleware to block anonymous requests, and the provider so client components can read the same session. ### Why does `getSessionFromCookies` return `null` even though I'm logged in? The two usual causes: (1) the client isn't using `storage: 'cookie'`, so the session is in `localStorage` where the server can't see it; or (2) on Next.js 15 you passed `cookies()` without awaiting it. `cookies()` is async in Next.js 15 — use `await getSessionFromCookies(await cookies(), …)`. ### Can I use this in Edge middleware? Yes. `getSessionFromCookies` has no Node-only dependencies and accepts `NextRequest.cookies`, so it runs in the Edge runtime. ## Related - **[Next.js Quickstart](nextjs.md)** — the client-side login flow this builds on. - **[Validate Access Tokens](../validate-access-tokens.md)** — verify `Bearer` tokens your API receives (different use case). - **[Authorization Code Flow with PKCE](../oauth-flows/authorization-code.md)** — what the SDK runs under the hood. --- ## Vue Authentication — OAuth PKCE Login for a Vue 3 SPA Source: https://faable.com/docs/auth/quickstart/vue # Vue Quickstart 💚 Add a complete login experience to a **Vue 3** single-page app. `@faable/auth-js` drives the [Authorization Code flow with PKCE](../oauth-flows/authorization-code.md), stores the session, refreshes tokens, and syncs across tabs. You wire it into Vue's reactivity with one small composable. This is the framework-agnostic core pattern — **`createClient` + `onAuthStateChange` + `getSession`** — wrapped in a `useAuth()` composable that exposes a reactive session `ref`. The [JavaScript](javascript.md), [SvelteKit](sveltekit.md), and [Angular](angular.md) quickstarts are the same three calls wired into each framework's reactivity. You need one package: **[`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js)** — Vue uses the core client directly, no framework helper required. --- ## ✅ Prerequisites In the [Faable Dashboard](https://dashboard.faable.com), create a **Client** for your SPA and configure: - **Allowed Callback URLs:** `http://localhost:5173/callback` (add your production URL later). - **Allowed Logout URLs:** `http://localhost:5173`. - **Allowed Web Origins:** `http://localhost:5173`. Note your **auth domain** (`your-domain.auth.faable.link`) and **Client ID**. SPAs are public clients — no client secret is involved. --- ## 🛠️ Step 1: Create the App and Install ```bash npm create vite@latest my-app -- --template vue-ts cd my-app npm install @faable/auth-js ``` ## Step 2: Create the Auth Client ```ts // src/auth-client.ts import { createClient } from '@faable/auth-js' export const auth = createClient({ domain: 'your-domain.auth.faable.link', clientId: 'YOUR_CLIENT_ID', redirectUri: window.location.origin + '/callback' }) ``` The client initializes itself on creation: it recovers an existing session from storage, or — on the callback URL — exchanges the PKCE `?code=` for tokens. ## Step 3: Wire a Reactive Session Composable Wrap the client in a `useAuth()` composable. It holds one shared `ref`, seeds it once with `getSession()`, and keeps it updated with `onAuthStateChange` — on login, logout, token refresh, or a change in another tab. Because the `ref` and subscription live at module scope, every component shares the same reactive session. ```ts // src/auth.ts import type { Session } from '@faable/auth-js' import { computed, ref } from 'vue' import { auth } from './auth-client' const session = ref(null) // Keep the ref in sync with every auth change (login, refresh, logout, other tabs). auth.onAuthStateChange((_event, next) => { session.value = next }) // Seed the initial value once (also auto-refreshes an expired session). auth.getSession().then(({ data }) => { session.value = data.session }) export function useAuth() { return { session, user: computed(() => session.value?.user ?? null), signIn: () => auth.signInWithOauthConnection({}), signOut: () => auth.signOut({ returnTo: window.location.origin }) } } ``` `onAuthStateChange` returns `{ data: { subscription } }`; call `subscription.unsubscribe()` if you ever need to tear the listener down. Here it lives for the app's lifetime, so we let it run. ## Step 4: Login, User, and Logout `App.vue` reads the composable. No session → a **Sign in** button; otherwise the user's email and a **Sign out** button. ```vue ``` - `signInWithOauthConnection({})` sends the user to your tenant's Universal Login with every [connection](../connections.md) you've enabled. Target one directly with `{ connection_id: "connection_..." }`. - `signOut({ returnTo })` clears the local session **and** the SSO cookie on the auth server. `returnTo` must be in **Allowed Logout URLs**. - Both methods redirect the browser on success — the promise intentionally never resolves, so don't put code after the `await`. The UI updates through the composable's `ref`, not a return value. ## Step 5: The Callback Route On `/callback`, call `handleRedirectCallback()` to finish the exchange, then go home. It's idempotent — the client already started the exchange on creation — and returns `{ error, returnTo }`, so deep links survive the login round-trip if you passed `returnTo` to `signInWithOauthConnection({ returnTo })`. ```vue ``` In a Vite SPA, make sure a request to `/callback` serves the same `index.html` — Vite's dev server does this by default, and in production you add an SPA rewrite so any path falls back to `index.html`. The `window.location.pathname` check in Step 4 keeps this router-free; drop in [Vue Router](https://router.vuejs.org/) and register `Callback.vue` as a `/callback` route if you prefer. ## Step 6: Call Your API The access token lives on the session. Read it fresh before each call — `getSession()` auto-refreshes an expired session: ```ts // src/api.ts import { auth } from './auth-client' export async function apiFetch(path: string) { const { data, error } = await auth.getSession() if (error || !data.session) throw new Error('Not signed in') return fetch(`https://api.myapp.com${path}`, { headers: { authorization: `Bearer ${data.session.access_token}` } }) } ``` If your backend validates the token's `audience`, pass your [API](../apis.md) identifier when creating the client (`createClient({ ..., audience: "https://api.myapp.com" })`) — and see [Validate Access Tokens](../validate-access-tokens.md) for the Express middleware on the other side. --- ## ❓ FAQ ### How do I make the session reactive in Vue 3? Hold it in a module-scoped `ref(null)`, update it from `onAuthStateChange`, and seed it once with `getSession()`. Return that `ref` from a `useAuth()` composable — Step 3 above. Every component that calls `useAuth()` shares the same reactive session, and templates re-render automatically on login, logout, and token refresh. ### Do I need a Vue-specific auth helper package? No. There is no `@faable/auth-helpers-vue` package — Vue uses **[`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js)** directly. The composable in Step 3 is all the wiring you need; it's a few lines around `createClient`, `onAuthStateChange`, and `getSession`. ### How do I get the access token in Vue? From the session: `const { data } = await auth.getSession()`, then `data.session?.access_token`. There is no separate `getAccessToken()` — `getSession()` already refreshes expired tokens before returning. Read it fresh right before each API call, as in Step 6. ### How do I send users straight to one provider? Pass `connection_id` to `signInWithOauthConnection` — e.g. `signIn: () => auth.signInWithOauthConnection({ connection_id: "connection_..." })`. Find the ID in **Dashboard → Auth → Connections**. Without it, users pick on the Universal Login screen. ### Why doesn't the sign-in promise resolve? `signInWithOauthConnection` and `signOut` redirect the browser on success, so the page unloads before the promise settles. Let the composable's `ref` drive your UI through `onAuthStateChange` — don't put post-login logic after the `await`. --- ## 🔗 Related - **[JavaScript](javascript.md)** · **[SvelteKit](sveltekit.md)** · **[Angular](angular.md)** — the same core client wired into each framework's reactivity. - **[React Quickstart](react.md)** · **[Next.js Quickstart](nextjs.md)** — with the React helper hooks. - **[Authorization Code Flow with PKCE](../oauth-flows/authorization-code.md)** — what the SDK does under the hood. - **[Validate Access Tokens](../validate-access-tokens.md)** — verify these tokens in your backend. - **[Connections](../connections.md)** — enable Google, GitHub, passwordless, and more. --- ## Nuxt Authentication — Add OAuth PKCE Login (Vue SSR) Source: https://faable.com/docs/auth/quickstart/nuxt # Nuxt Quickstart 💚 Add a complete login experience to a **Nuxt 3** app. `@faable/auth-js` drives the [Authorization Code flow with PKCE](../oauth-flows/authorization-code.md), stores the session, refreshes tokens, and syncs across tabs. The one thing to get right in Nuxt: the SDK is **client-only**. It uses `window`, `localStorage`, and PKCE, so it can't run during server-side rendering. You create it in a **client plugin** (`plugins/faable.client.ts` — the `.client` suffix makes Nuxt run it only in the browser) and expose the session through a `useState`-backed composable so it survives hydration. This is still the framework-agnostic core pattern — **`createClient` + `onAuthStateChange` + `getSession`** — wired into Nuxt's SSR-safe state. The [Vue](vue.md), [JavaScript](javascript.md), [SvelteKit](sveltekit.md), and [Angular](angular.md) quickstarts are the same three calls in each framework. You need one package: **[`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js)** — Nuxt uses the core client directly, no framework helper required. --- ## ✅ Prerequisites In the [Faable Dashboard](https://dashboard.faable.com), create a **Client** for your app and configure: - **Allowed Callback URLs:** `http://localhost:3000/callback` (add your production URL later). - **Allowed Logout URLs:** `http://localhost:3000`. - **Allowed Web Origins:** `http://localhost:3000`. Note your **auth domain** (`your-domain.auth.faable.link`) and **Client ID**. Browser apps are public clients — no client secret is involved. --- ## 🛠️ Step 1: Create the App and Install ```bash npm create nuxt@latest my-app # or: npx nuxi init my-app cd my-app npm install @faable/auth-js ``` ## Step 2: Create the Client in a Client Plugin The SDK touches `window` and PKCE storage, so it must never run on the server. A plugin named `*.client.ts` runs **only in the browser** — exactly where you want it. Create the client there and provide it to the rest of the app via `nuxtApp.provide`. ```ts // plugins/faable.client.ts import { createClient } from '@faable/auth-js' export default defineNuxtPlugin(() => { const auth = createClient({ domain: 'your-domain.auth.faable.link', clientId: 'YOUR_CLIENT_ID', redirectUri: window.location.origin + '/callback' }) return { provide: { auth } } }) ``` `provide: { auth }` exposes the client as `useNuxtApp().$auth` everywhere in the app. On creation the client recovers an existing session from storage, or — on the callback URL — exchanges the PKCE `?code=` for tokens. ## Step 3: Wire a Reactive Session Composable Wrap the client in a `useAuth()` composable. It holds the session in Nuxt's `useState` — SSR-safe shared state that survives hydration — seeds it once with `getSession()`, and keeps it updated with `onAuthStateChange` on login, logout, token refresh, or a change in another tab. All of that runs client-side, guarded by `import.meta.client`. ```ts // composables/useAuth.ts import type { Session } from '@faable/auth-js' export function useAuth() { const { $auth } = useNuxtApp() const session = useState('faable-session', () => null) // Only the browser has $auth (client plugin) and window. if (import.meta.client && $auth) { // Keep state in sync with every auth change (login, refresh, logout, other tabs). $auth.onAuthStateChange((_event, next) => { session.value = next }) // Seed the initial value once (also auto-refreshes an expired session). $auth.getSession().then(({ data }) => { session.value = data.session }) } return { auth: $auth, session, user: computed(() => session.value?.user ?? null), signIn: () => $auth?.signInWithOauthConnection({}), signOut: () => $auth?.signOut({ returnTo: window.location.origin }) } } ``` `onAuthStateChange` returns `{ data: { subscription } }`; call `subscription.unsubscribe()` if you ever need to tear the listener down. Here it lives for the app's lifetime, so we let it run. ## Step 4: Login, User, and Logout Any page or component reads the composable. Because sign-in state only exists in the browser, wrap the auth UI in `` — it renders nothing on the server and mounts the real UI after hydration, so there's no flash of the wrong state. ```vue ``` - `signInWithOauthConnection({})` sends the user to your tenant's Universal Login with every [connection](../connections.md) you've enabled. Target one directly with `{ connection_id: "connection_..." }`. - `signOut({ returnTo })` clears the local session **and** the SSO cookie on the auth server. `returnTo` must be in **Allowed Logout URLs**. - Both methods redirect the browser on success — the promise intentionally never resolves, so don't put code after the `await`. The UI updates through the composable's `useState`, not a return value. ## Step 5: The Callback Page Add a `/callback` page that finishes the exchange, then navigates home. Run `handleRedirectCallback()` in `onMounted` so it only fires in the browser. It's idempotent — the client already started the exchange on creation — and returns `{ error, returnTo }`, so deep links survive the login round-trip if you passed `returnTo` to `signInWithOauthConnection({ returnTo })`. ```vue ``` Nuxt's file-based router serves this at `/callback` automatically — the same path you registered as your **Allowed Callback URL**. `onMounted` never runs during SSR, so the SDK stays client-only. ## Step 6: Call Your API The access token lives on the session. Read it fresh before each call — `getSession()` auto-refreshes an expired session: ```ts // composables/apiFetch.ts export async function apiFetch(path: string) { const { $auth } = useNuxtApp() const { data, error } = await $auth.getSession() if (error || !data.session) throw new Error('Not signed in') return fetch(`https://api.myapp.com${path}`, { headers: { authorization: `Bearer ${data.session.access_token}` } }) } ``` If your backend validates the token's `audience`, pass your [API](../apis.md) identifier when creating the client (`createClient({ ..., audience: "https://api.myapp.com" })`) — and see [Validate Access Tokens](../validate-access-tokens.md) for the Express middleware on the other side. --- ## ❓ FAQ ### Why a client plugin instead of a normal plugin? `@faable/auth-js` uses `window`, `localStorage`, and PKCE, none of which exist during Nuxt's server-side render. A plugin named `faable.client.ts` runs **only in the browser** — the `.client` suffix is Nuxt's built-in signal. Create the client there and `provide` it, and it never executes on the server. Guard any composable logic with `import.meta.client` for the same reason. ### How do I keep the session reactive without it breaking hydration? Hold it in `useState("faable-session", () => null)` — Nuxt's SSR-safe shared state — update it from `onAuthStateChange`, and seed it once with `getSession()` (Step 3). Every component that calls `useAuth()` shares the same state, and wrapping auth UI in `` avoids a server/client mismatch since the real session only exists in the browser. ### How do I get the access token in Nuxt? From the session: `const { data } = await $auth.getSession()`, then `data.session?.access_token`. There is no separate `getAccessToken()` — `getSession()` already refreshes expired tokens before returning. Read it fresh right before each API call, as in Step 6. ### How do I send users straight to one provider? Pass `connection_id` to `signInWithOauthConnection` — e.g. `signIn: () => $auth.signInWithOauthConnection({ connection_id: "connection_..." })`. Find the ID in **Dashboard → Auth → Connections**. Without it, users pick on the Universal Login screen. ### Why doesn't the sign-in promise resolve? `signInWithOauthConnection` and `signOut` redirect the browser on success, so the page unloads before the promise settles. Let the composable's `useState` drive your UI through `onAuthStateChange` — don't put post-login logic after the `await`. --- ## 🔗 Related - **[Vue](vue.md)** · **[JavaScript](javascript.md)** · **[SvelteKit](sveltekit.md)** · **[Angular](angular.md)** — the same core client wired into each framework's reactivity. - **[React Quickstart](react.md)** · **[Next.js Quickstart](nextjs.md)** — with the React helper hooks. - **[Authorization Code Flow with PKCE](../oauth-flows/authorization-code.md)** — what the SDK does under the hood. - **[Validate Access Tokens](../validate-access-tokens.md)** — verify these tokens in your backend. - **[Connections](../connections.md)** — enable Google, GitHub, passwordless, and more. --- ## SvelteKit Authentication — Add OAuth PKCE Login Source: https://faable.com/docs/auth/quickstart/sveltekit # SvelteKit Quickstart 🧡 Add a complete login experience to a **SvelteKit** app. `@faable/auth-js` drives the [Authorization Code flow with PKCE](../oauth-flows/authorization-code.md), stores the session, refreshes tokens, and syncs across tabs. You wire it into Svelte's reactivity with a single `writable` store. This is the same framework-agnostic core as the [JavaScript quickstart](javascript.md) — **`createClient` + `onAuthStateChange` + `getSession`** — expressed idiomatically in Svelte. There's no `@faable/auth-helpers-svelte` package: Svelte uses the core client directly. You need one package: **[`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js)** — no framework helper required. --- ## ✅ Prerequisites In the [Faable Dashboard](https://dashboard.faable.com), create a **Client** for your SPA and configure: - **Allowed Callback URLs:** `http://localhost:5173/callback` (add your production URL later). - **Allowed Logout URLs:** `http://localhost:5173`. - **Allowed Web Origins:** `http://localhost:5173`. Note your **auth domain** (`your-domain.auth.faable.link`) and **Client ID**. SPAs are public clients — no client secret is involved. --- ## 🛠️ Step 1: Create the App and Install ```bash npx sv create my-app cd my-app npm install @faable/auth-js ``` Choose the **SvelteKit minimal** template and **TypeScript** when the scaffolder prompts. ## Step 2: Create the Auth Client and Disable SSR `createClient` touches `window` (PKCE runs in the browser), so the client must never run on the server. The simplest setup for a SPA is to turn SSR off for the whole app — add a root `+layout.ts`: ```ts // src/routes/+layout.ts export const ssr = false ``` Now create the client. Keeping it in `src/lib` makes it importable everywhere via the `$lib` alias: ```ts // src/lib/auth.ts import { createClient } from '@faable/auth-js' export const auth = createClient({ domain: 'your-domain.auth.faable.link', clientId: 'YOUR_CLIENT_ID', redirectUri: window.location.origin + '/callback' }) ``` The client initializes itself on creation: it recovers an existing session from storage, or — on the callback URL — exchanges the PKCE `?code=` for tokens. ## Step 3: Expose the Session as a Store Wrap the SDK's session in a Svelte `writable` so any component can read it reactively. Subscribe once with `onAuthStateChange` to keep the store fresh on login, logout, token refresh, and cross-tab changes — then seed it once with `getSession()`. ```ts // src/lib/auth.ts (continued) import type { Session } from '@faable/auth-js' import { writable } from 'svelte/store' export const session = writable(null) // Update the store on every auth change… auth.onAuthStateChange((_event, current) => session.set(current)) // …and seed it once with the current session. auth.getSession().then(({ data }) => session.set(data.session)) ``` `getSession()` returns (and auto-refreshes) the current session; `onAuthStateChange` fires on every subsequent change. ## Step 4: Login, User, and Logout Read the store with Svelte's `$` auto-subscription — no session shows a **Sign in** button, a session shows the user and a **Sign out** button. ```svelte {#if !$session} {:else}

Hello {$session.user.email}

{/if} ``` - `signInWithOauthConnection({})` sends the user to your tenant's Universal Login with every [connection](../connections.md) you've enabled. Target one directly with `{ connection_id: "connection_..." }`. - `signOut({ returnTo })` clears the local session **and** the SSO cookie on the auth server. `returnTo` must be in **Allowed Logout URLs**. - Both methods redirect the browser on success — the promise intentionally never resolves, so don't put code after the `await`. Post-login logic belongs in `onAuthStateChange` (Step 3), which already updates the store. ## Step 5: Add the Callback Route Create a `/callback` route that finishes the login and returns home. `handleRedirectCallback()` awaits the code-for-tokens exchange (it's idempotent — the client already started it) and returns `{ error, returnTo }`, so deep links survive the round-trip if you passed `returnTo` to `signInWithOauthConnection({ returnTo })`. ```svelte

{message}

``` ## Step 6: Call Your API The access token lives on the session. Read it fresh before each call — `getSession()` auto-refreshes an expired session: ```ts // src/lib/api.ts import { auth } from '$lib/auth' export async function apiFetch(path: string) { const { data, error } = await auth.getSession() if (error || !data.session) throw new Error('Not signed in') return fetch(`https://api.myapp.com${path}`, { headers: { authorization: `Bearer ${data.session.access_token}` } }) } ``` If your backend validates the token's `audience`, pass your [API](../apis.md) identifier when creating the client (`createClient({ ..., audience: "https://api.myapp.com" })`) — and see [Validate Access Tokens](../validate-access-tokens.md) for the Express middleware on the other side. --- ## ❓ FAQ ### Why do I need `export const ssr = false`? `createClient` and the PKCE flow use `window` and `localStorage`, which don't exist during server-side rendering. Disabling SSR in the root `+layout.ts` runs the app as a client-side SPA so the auth client is only ever constructed in the browser. If you want to keep SSR for other routes, guard the client-side code with `browser` from `$app/environment` instead. ### How do I read the session in a component? Import the `session` store and use `$session` — Svelte's `$` prefix auto-subscribes and re-renders on every change (login, logout, refresh, cross-tab sync). The user is at `$session.user` (e.g. `$session.user.email`). ### How do I get the access token? From the session: `const { data } = await auth.getSession()`, then `data.session?.access_token`. There is no separate `getAccessToken()` — `getSession()` already refreshes expired tokens before returning. ### How do I send users straight to one provider? Pass `connection_id` to `signInWithOauthConnection` — e.g. `{ connection_id: "connection_..." }`. Find the ID in **Dashboard → Auth → Connections**. Without it, users pick on the Universal Login screen. --- ## 🔗 Related - **[JavaScript](javascript.md)** · **[Vue](vue.md)** · **[Angular](angular.md)** — the same pattern in other frameworks. - **[React Quickstart](react.md)** · **[Next.js Quickstart](nextjs.md)** — with the React helper hooks. - **[Authorization Code Flow with PKCE](../oauth-flows/authorization-code.md)** — what the SDK does under the hood. - **[Validate Access Tokens](../validate-access-tokens.md)** — verify these tokens in your backend. - **[Connections](../connections.md)** — enable Google, GitHub, passwordless, and more. --- ## Angular Authentication — OAuth PKCE Login with Faable Auth Source: https://faable.com/docs/auth/quickstart/angular # Angular Quickstart 🅰️ Add a complete login experience to a **standalone Angular** app (Angular 17+). `@faable/auth-js` drives the [Authorization Code flow with PKCE](../oauth-flows/authorization-code.md), stores the session, refreshes tokens, and syncs across tabs. You wire it to Angular's reactivity with a single root service that exposes the session as a **signal**. This is the framework-agnostic core pattern: **`createClient` + `onAuthStateChange` + `getSession`**. The [JavaScript](javascript.md), [Vue](vue.md), and [SvelteKit](sveltekit.md) quickstarts are the same three calls wired into each framework — here they live in an injectable `AuthService`. You need one package: **[`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js)** — no Angular helper required. --- ## ✅ Prerequisites In the [Faable Dashboard](https://dashboard.faable.com), create a **Client** for your SPA and configure: - **Allowed Callback URLs:** `http://localhost:4200/callback` (add your production URL later). - **Allowed Logout URLs:** `http://localhost:4200`. - **Allowed Web Origins:** `http://localhost:4200`. Note your **auth domain** (`your-domain.auth.faable.link`) and **Client ID**. SPAs are public clients — no client secret is involved. --- ## 🛠️ Step 1: Create the App and Install ```bash npm install -g @angular/cli ng new my-app cd my-app npm install @faable/auth-js ``` Answer the `ng new` prompts however you like — this guide uses standalone components (the default since Angular 17) and the Angular Router. ## Step 2: Create the Auth Service The whole integration lives in one root-provided service. It creates the client once, seeds the session with `getSession()`, and keeps a `session` **signal** in sync with `onAuthStateChange` — login, logout, token refresh, or a change in another tab. ```ts // src/app/auth.service.ts import { Injectable, signal, computed } from "@angular/core"; import { createClient, type Session } from "@faable/auth-js"; @Injectable({ providedIn: "root" }) export class AuthService { private auth = createClient({ domain: "your-domain.auth.faable.link", clientId: "YOUR_CLIENT_ID", redirectUri: window.location.origin + "/callback", }); /** Current session, reactive. `null` when signed out. */ readonly session = signal(null); /** Convenience: the signed-in user, or `undefined`. */ readonly user = computed(() => this.session()?.user); constructor() { // Keep the signal in sync with every auth change. this.auth.onAuthStateChange((_event, session) => this.session.set(session)); // Seed the initial value (also auto-refreshes an expired session). this.auth.getSession().then(({ data }) => this.session.set(data.session)); } signIn() { // Universal Login with every connection you've enabled. return this.auth.signInWithOauthConnection({}); } signOut() { return this.auth.signOut({ returnTo: window.location.origin }); } /** Finish the login on the /callback route. */ handleCallback() { return this.auth.handleRedirectCallback(); } /** A fresh access token for API calls (auto-refreshed). */ async accessToken() { const { data } = await this.auth.getSession(); return data.session?.access_token ?? null; } } ``` The client initializes itself on creation: it recovers an existing session from storage, or — on the callback URL — exchanges the PKCE `?code=` for tokens. ## Step 3: Wire It Up There's no extra wiring step — because `AuthService` is `providedIn: "root"`, Angular constructs it lazily the first time you inject it, and the constructor above subscribes to `onAuthStateChange` and seeds the `session` signal. Every component that injects `AuthService` reads the same live signal. - `signInWithOauthConnection({})` sends the user to your tenant's Universal Login with every [connection](../connections.md) you've enabled. Target one directly with `{ connection_id: "connection_..." }`. - `signOut({ returnTo })` clears the local session **and** the SSO cookie on the auth server. `returnTo` must be in **Allowed Logout URLs**. - Both methods redirect the browser on success — the promise intentionally never resolves, so don't put code after the `await`. ## Step 4: Login, User, and Logout Inject the service and read the signal in the template with the new control flow (`@if`): ```ts // src/app/app.component.ts import { Component, inject } from "@angular/core"; import { RouterOutlet } from "@angular/router"; import { AuthService } from "./auth.service"; @Component({ selector: "app-root", standalone: true, imports: [RouterOutlet], template: ` @if (!auth.session()) { } @else {

Hello {{ auth.user()?.email }}

} `, }) export class AppComponent { auth = inject(AuthService); } ``` Signals make this reactive with zero boilerplate: when `onAuthStateChange` updates `session`, the template re-renders. No `async` pipe, no manual subscription cleanup. ## Step 5: The Callback Route Add a `callback` route that runs `handleRedirectCallback()` once, then navigates home. It's idempotent — the client already started the exchange — and returns `{ error, returnTo }`, so deep links survive the login round-trip if you passed `returnTo` to `signInWithOauthConnection({ returnTo })`. ```ts // src/app/callback.component.ts import { Component, inject, signal, OnInit } from "@angular/core"; import { Router } from "@angular/router"; import { AuthService } from "./auth.service"; @Component({ selector: "app-callback", standalone: true, template: `

{{ message() }}

`, }) export class CallbackComponent implements OnInit { private auth = inject(AuthService); private router = inject(Router); message = signal("Signing you in…"); async ngOnInit() { const { error, returnTo } = await this.auth.handleCallback(); if (error) this.message.set(error.message); else this.router.navigateByUrl(returnTo ?? "/"); } } ``` Register the route and bootstrap the app: ```ts // src/app/app.routes.ts import { Routes } from '@angular/router' import { CallbackComponent } from './callback.component' export const routes: Routes = [ { path: 'callback', component: CallbackComponent } ] ``` ```ts // src/main.ts import { bootstrapApplication } from '@angular/platform-browser' import { provideRouter } from '@angular/router' import { AppComponent } from './app/app.component' import { routes } from './app/app.routes' bootstrapApplication(AppComponent, { providers: [provideRouter(routes)] }) ``` ## Step 6: Call Your API Read the token fresh before each call — `AuthService.accessToken()` wraps `getSession()`, which auto-refreshes an expired session: ```ts // src/app/api.service.ts import { Injectable, inject } from "@angular/core"; import { AuthService } from "./auth.service"; @Injectable({ providedIn: "root" }) export class ApiService { private auth = inject(AuthService); async fetch(path: string) { const token = await this.auth.accessToken(); if (!token) throw new Error("Not signed in"); return fetch(`https://api.myapp.com${path}`, { headers: { authorization: `Bearer ${token}` }, }); } } ``` If your backend validates the token's `audience`, pass your [API](../apis.md) identifier when creating the client (`createClient({ ..., audience: "https://api.myapp.com" })`) — and see [Validate Access Tokens](../validate-access-tokens.md) for the Express middleware on the other side. > **Optional:** to attach the token to every `HttpClient` request instead, register a functional [HTTP interceptor](https://angular.dev/guide/http/interceptors) that reads `AuthService.accessToken()` and sets the `Authorization` header. The `fetch` service above keeps the example dependency-free. --- ## ❓ FAQ ### Why expose the session as a signal instead of an Observable? Signals are the modern Angular primitive for reactive state: they read like a plain value in templates (`auth.session()`), need no `async` pipe, and never leak subscriptions. `onAuthStateChange` pushes each change straight into `session.set(...)`. If you prefer RxJS, wrap the signal with `toObservable()`. ### How do I get the access token? From the session: `const { data } = await auth.getSession()`, then `data.session?.access_token`. The `accessToken()` helper on `AuthService` does exactly this. There is no separate `getAccessToken()` — `getSession()` already refreshes expired tokens before returning. ### How do users pick a login method? By default they choose on the Universal Login screen among the [connections](../connections.md) enabled for your client. To skip the screen and go straight to one provider, pass `connection_id` to `signInWithOauthConnection` — e.g. `{ connection_id: "connection_..." }`. ### Why doesn't the sign-in promise resolve? `signInWithOauthConnection` and `signOut` redirect the browser on success, so the page unloads before the promise settles. Let the `onAuthStateChange` subscription update the `session` signal — don't put post-login logic after the `await`. --- ## 🔗 Related - **[JavaScript](javascript.md)** · **[Vue](vue.md)** · **[SvelteKit](sveltekit.md)** — the same pattern in a framework. - **[React Quickstart](react.md)** · **[Next.js Quickstart](nextjs.md)** — with the React helper hooks. - **[Authorization Code Flow with PKCE](../oauth-flows/authorization-code.md)** — what the SDK does under the hood. - **[Validate Access Tokens](../validate-access-tokens.md)** — verify these tokens in your backend. - **[Connections](../connections.md)** — enable Google, GitHub, passwordless, and more. --- ## JavaScript Quickstart — OAuth PKCE Login for a Vanilla SPA Source: https://faable.com/docs/auth/quickstart/javascript # JavaScript Quickstart 🟨 Add a complete login experience to a **plain JavaScript** single-page app — no framework. `@faable/auth-js` drives the [Authorization Code flow with PKCE](../oauth-flows/authorization-code.md), stores the session, refreshes tokens, and syncs across tabs. You wire it to the DOM with one subscription. This is the framework-agnostic core pattern: **`createClient` + `onAuthStateChange` + `getSession`**. The [Vue](vue.md), [SvelteKit](sveltekit.md), and [Angular](angular.md) quickstarts are the same three calls wired into each framework's reactivity. You need one package: **[`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js)** — no framework helper required. --- ## ✅ Prerequisites In the [Faable Dashboard](https://dashboard.faable.com), create a **Client** for your SPA and configure: - **Allowed Callback URLs:** `http://localhost:5173/callback` (add your production URL later). - **Allowed Logout URLs:** `http://localhost:5173`. - **Allowed Web Origins:** `http://localhost:5173`. Note your **auth domain** (`your-domain.auth.faable.link`) and **Client ID**. SPAs are public clients — no client secret is involved. --- ## 🛠️ Step 1: Create the App and Install ```bash npm create vite@latest my-app -- --template vanilla-ts cd my-app npm install @faable/auth-js ``` ## Step 2: Create the Auth Client ```ts // src/auth.ts import { createClient } from '@faable/auth-js' export const auth = createClient({ domain: 'your-domain.auth.faable.link', clientId: 'YOUR_CLIENT_ID', redirectUri: window.location.origin + '/callback' }) ``` The client initializes itself on creation: it recovers an existing session from storage, or — on the callback URL — exchanges the PKCE `?code=` for tokens. ## Step 3: Render on Auth State Subscribe once with `onAuthStateChange` and re-render whenever the session changes — login, logout, token refresh, or a change in another tab. `getSession()` returns the current session (and auto-refreshes it if expired). ```ts // src/main.ts import { auth } from './auth' const app = document.querySelector('#app')! async function render() { // On the callback route, finish the login and go home (Step 5) if (location.pathname === '/callback') { const { error, returnTo } = await auth.handleRedirectCallback() if (error) return void (app.textContent = `Sign-in failed: ${error.message}`) return void location.replace(returnTo ?? '/') } const { data } = await auth.getSession() if (!data.session) { app.innerHTML = `` document .querySelector('#login')! .addEventListener('click', () => auth.signInWithOauthConnection({})) return } app.innerHTML = `

Hello ${data.session.user.email}

` document .querySelector('#logout')! .addEventListener('click', () => auth.signOut({ returnTo: location.origin }) ) } // Re-render on every auth change, then do the first render. auth.onAuthStateChange(() => render()) render() ``` - `signInWithOauthConnection({})` sends the user to your tenant's Universal Login with every [connection](../connections.md) you've enabled. Target one directly with `{ connection_id: "connection_..." }`. - `signOut({ returnTo })` clears the local session **and** the SSO cookie on the auth server. `returnTo` must be in **Allowed Logout URLs**. - Both methods redirect the browser on success — the promise intentionally never resolves, so don't put code after the `await`. ## Step 4: Add the Callback Route The `render()` above already handles `/callback` by calling `handleRedirectCallback()`. In a Vite SPA, make sure a request to `/callback` serves the same `index.html` (add a rewrite in `vite.config.ts`, or use hash routing). `handleRedirectCallback()` awaits the code-for-tokens exchange (it's idempotent — the client already started it) and returns `{ error, returnTo }`, so deep links survive the login round-trip if you passed `returnTo` to `signInWithOauthConnection({ returnTo })`. ## Step 5: Call Your API The access token lives on the session. Read it fresh before each call — `getSession()` auto-refreshes an expired session: ```ts // src/api.ts import { auth } from './auth' export async function apiFetch(path: string) { const { data, error } = await auth.getSession() if (error || !data.session) throw new Error('Not signed in') return fetch(`https://api.myapp.com${path}`, { headers: { authorization: `Bearer ${data.session.access_token}` } }) } ``` If your backend validates the token's `audience`, pass your [API](../apis.md) identifier when creating the client (`createClient({ ..., audience: "https://api.myapp.com" })`) — and see [Validate Access Tokens](../validate-access-tokens.md) for the Express middleware on the other side. --- ## ❓ FAQ ### How do I get the access token in plain JavaScript? From the session: `const { data } = await auth.getSession()`, then `data.session?.access_token`. There is no separate `getAccessToken()` — `getSession()` already refreshes expired tokens before returning. ### How do I read custom claims (roles, tenant, station…) in the client? Two ways, both fed by the same access token: - `session.user` already carries the custom claims your tenant puts on the token (an Action's `api.accessToken.setCustomClaim` or a connection's `claims_mapping`) as top-level properties, because `/me` returns them: `data.session.user['ciapol.com/station_id']`. - `auth.getClaims<{ 'ciapol.com/station_id': string }>()` decodes the token locally (standard claims like `sub`, `scope`, `exp` plus your custom ones) and refreshes an expired session first, like `getSession()`. `auth.getClaim('ciapol.com/station_id')` returns a single value or `null`. Claims are decoded, not signature-verified: use them for UI and routing, never as authorization — the API that receives the token is what validates it. See [Actions](../extensibility/actions.md#example--custom-claims-on-every-token). ### Do I need a framework or a build step? No. `@faable/auth-js` is framework-agnostic and works with any bundler (Vite here) or plain ES modules. The [React](react.md), [Vue](vue.md), [SvelteKit](sveltekit.md), and [Angular](angular.md) quickstarts wire the same client into each framework. ### How do I send users straight to one provider? Pass `connection_id` to `signInWithOauthConnection` — e.g. `{ connection_id: "connection_..." }`. Find the ID in **Dashboard → Auth → Connections**. Without it, users pick on the Universal Login screen. ### Why doesn't the sign-in promise resolve? `signInWithOauthConnection` and `signOut` redirect the browser on success, so the page unloads before the promise settles. Put post-login logic in the `onAuthStateChange` handler, not after the `await`. --- ## 🔗 Related - **[Vue](vue.md)** · **[SvelteKit](sveltekit.md)** · **[Angular](angular.md)** — the same pattern in a framework. - **[React Quickstart](react.md)** · **[Next.js Quickstart](nextjs.md)** — with the React helper hooks. - **[Authorization Code Flow with PKCE](../oauth-flows/authorization-code.md)** — what the SDK does under the hood. - **[Validate Access Tokens](../validate-access-tokens.md)** — verify these tokens in your backend. - **[Connections](../connections.md)** — enable Google, GitHub, passwordless, and more. --- ## React Native Quickstart Source: https://faable.com/docs/auth/quickstart/react-native # React Native In this guide we are going to implement the Faable Auth authentication service in React Native. The Identity Provider enables us to log in using Google, GitHub, Microsoft or any other OAuth 2.0 provider, with a single configuration. > ⚠️ Before starting: > > - Make sure you have the simulator installed according to the platform you are developing on, ios or Android. > - Use your favorite code editor, in this case we will use VSCode. ![](/quickstart/login_button.webp) ## 🚀 Start Open a new terminal to create a new project. ```bash npx create-expo-app --template ``` Complete the installation questions. 1. Choose “Blank (TypeScript)” 2. Name your app 3. Wait for npm to install packages ```js $ npx create-expo-app --template Need to install the following packages: create-expo-app@2.3.1 Ok to proceed? (y) y ✔ Choose a template: › Blank (TypeScript) ✔ What is your app named? … faable-login-expo ✔ Downloaded and extracted project files. > npm install added 1243 packages, and audited 1244 packages in 30s ✅ Your project is ready! To run your project, navigate to the directory and run one of the following npm commands. - cd faable-login-expo - npm run android - npm run ios - npm run web ``` Once the installation is complete, we run the project to check that everything is working correctly. ```bash npm run ios ``` We will end up with this folder structure. ```txt 📁faable-login-expo └── .gitignore └── app.json └── App.tsx └── 📁assets └── adaptive-icon.png └── favicon.png └── icon.png └── splash.png └── babel.config.js └── package-lock.json └── package.json └── tsconfig.json ``` ## 🧩 Login Button We create a new file where we put the component, we call it `LoginButton.tsx` here's the code for it. ```tsx export const LoginButton = () => { return ( null}> Login ) } ``` Modify the `App.tsx` file to add our `` component for the user to log in. ```tsx export default function App() { return ( ) } ``` ## ⚙️ Setup credentials ![](/quickstart/google_authorization_server.webp) Create an auth folder and inside it create a `faableauth.ts` file and write all the necessary configuration. Install **Faable Auth** required packages. ```bash npm i @faable/auth-js @faable/auth-helpers-react expo-auth-session react-native-url-polyfill @react-native-async-storage/async-storage ``` Start writing code inside `faableauth.ts` file as required by expo to properly configure user session handling with oauth2 protocol. ```tsx import "react-native-url-polyfill/auto"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { createClient } from "@faable/auth-js"; import { makeRedirectUri } from "expo-auth-session"; import _ as WebBrowser from "expo-web-browser"; import _ as QueryParams from "expo-auth-session/build/QueryParams"; WebBrowser.maybeCompleteAuthSession(); // Required for web only const redirectTo = makeRedirectUri(); // Redirection URI ``` > ⚠️ WARNING: importing the `react-native-url-polyfill/auto` library is required for `@faable/auth-js` to work properly. create an instance of the client that will be shared across the entire application and place the configuration credentials for Faable Auth. ```tsx import { createClient } from '@faable/auth-js' import AsyncStorage from '@react-native-async-storage/async-storage' import 'react-native-url-polyfill/auto' const faableAuthUrl = 'https://.auth.faable.link' const clientId = '' export const faableAuth = createClient({ domain: faableAuthUrl, clientId, storage: AsyncStorage }) ``` > ✅ Use the credentials you'll find on the [Faable Dashboard](https://dashboard.faable.com). Faable Auth is in beta so you'll need to request access by joining our [Discord channel](https://discord.gg/98JtRzYp). ```js const createSessionFromUrl = async (url: string) => { const { params, errorCode } = QueryParams.getQueryParams(url); if (errorCode) throw new Error(errorCode); const { access_token, refresh_token } = params; if (!access_token) return; const { data, error } = await faableAuth.setSession({ access_token, refresh_token, }); if (error) throw error; return data.session; }; // Oauth2 Login with FaableAuth const login = async () => { try { const { data, error } = await faableAuth.signInWithOauthConnection({ redirectTo, skipBrowserRedirect: true, connection: "connection_xxxxxxxxxxxxxxxxxxxxxx", queryParams: { prompt: "login", }, }); if (error) throw error; const res = await WebBrowser.openAuthSessionAsync( data?.url ?? "", redirectTo ); if (res.type === "success") { const { url } = res; await createSessionFromUrl(url); } } catch (e) { console.error(e); } }; // Clear session const logout = async () => faableAuth.signOut(); ``` > ⚠️ **IMPORTANT**: You must define which social connection you are going to use to log in. You will find the connection IDs in the dashboard. The connection IDs have this format `connection_xxxxxxxxxxxxxxxxxxxxxx` We define a global context for the entire application in the main project file `App.tsx`. Configure the context by passing as a prop the instance of faableauth that we have previously configured. ```tsx export default function App() { return ( ) } ``` ## 👤 User Profile ![](/quickstart/login_successful.webp) Return to the `LoginButton.tsx` file to place the needed logic that allows us to display the user's information when the user has completed the login flow. To do this, we will use the `useSession()` hook provided with our helper library `@faable/auth-helpers-react` that allows us to **access the session and user data**. ```tsx import { useSession } from '@faable/auth-helpers-react' import { Image } from 'expo-image' import { StyleSheet, Text, TouchableOpacity, View } from 'react-native' import { login, logout } from '../../lib/auth/faableauth' export const LoginButton = () => { const session = useSession() return ( <> {!session && ( login()} style={styles.login_button}> Login )} {session && ( {session.user.name} logout()} style={styles.logout_button} > Logout )} ) } ``` **Links:** Here's the code repository with al, so you can clone it. - [React-Native-Expo-FaableAuth Example Code](https://github.com/faablecloud/faableauth-examples/tree/main/react-native-expo) --- ## FastAPI Quickstart — Protect a Python API with Faable Auth Source: https://faable.com/docs/auth/quickstart/fastapi # FastAPI Quickstart 🐍 Turn a **FastAPI** app into an OAuth 2.0 **resource server**: every request arrives with a `Bearer` access token, and your API verifies it locally before running any handler. ``` Authorization: Bearer eyJhbGciOiJSUzI1NiIs... ``` Verification is **fully local**. Your API checks the RS256 signature against your tenant's public keys (JWKS), which PyJWT caches — Faable is only contacted when the cache is cold or a key rotates. No network call per request, no shared secret to distribute. > **This guide protects an API, it does not log users in.** Faable has no Python login SDK. The browser side of the flow belongs in your frontend ([React](react.md), [Next.js](nextjs.md), [Vue](vue.md)…) or in a machine client using [Client Credentials](../oauth-flows/client-credentials.md). FastAPI's job is to validate whatever token they send. --- ## ✅ Prerequisites In the [Faable Dashboard](https://dashboard.faable.com): 1. **Register your API** under **Auth → APIs**. Its **identifier** (e.g. `https://api.myapp.com`) is what lands in the token's `aud` claim — it's an opaque identifier, not a URL that has to resolve. See [APIs](../apis.md). 2. Optionally declare a **permission catalog** (`read:orders`, `write:orders`…) and set the token dialect to `access_token_authz` so tokens carry a `permissions` claim. 3. Note your **auth domain** — , or your [custom domain](../custom-domain.md). Install the dependencies: ```bash pip install "fastapi[standard]" "pyjwt[crypto]" ``` `pyjwt[crypto]` pulls in `cryptography`, which PyJWT needs to verify RS256 signatures. Without the extra, every verification fails with `InvalidAlgorithmError`. --- ## 🔐 Step 1: The Token Verifier A valid token must pass four checks — signature, issuer, audience, expiry. `PyJWKClient` resolves the right public key from the token header's `kid`, so key rotation is handled for you: ```python # auth.py import jwt from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from typing import Annotated ISSUER = "https://your-domain.auth.faable.link" # NO trailing slash AUDIENCE = "https://api.myapp.com" # your API identifier # Caches the JWKS and re-fetches when an unknown `kid` shows up. jwks_client = jwt.PyJWKClient(f"{ISSUER}/.well-known/jwks.json") bearer = HTTPBearer(auto_error=False) def verify_token( creds: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], ) -> dict: if creds is None: raise HTTPException( status.HTTP_401_UNAUTHORIZED, detail="missing_token", headers={"WWW-Authenticate": "Bearer"}, ) try: signing_key = jwks_client.get_signing_key_from_jwt(creds.credentials) return jwt.decode( creds.credentials, signing_key.key, algorithms=["RS256"], audience=AUDIENCE, issuer=ISSUER, ) except jwt.PyJWTError as exc: raise HTTPException( status.HTTP_401_UNAUTHORIZED, detail="invalid_token", headers={"WWW-Authenticate": 'Bearer error="invalid_token"'}, ) from exc Claims = Annotated[dict, Depends(verify_token)] ``` > **Why `def` and not `async def`?** `PyJWKClient` fetches the JWKS with a **blocking** HTTP call. Declared as a plain `def`, FastAPI runs the dependency in a threadpool and the event loop keeps serving other requests during that fetch. An `async def` would stall every concurrent request whenever the key cache expires. This is the single most common way a correct-looking FastAPI verifier degrades under load. --- ## 🛡️ Step 2: Enforce Scopes `scope` and `permissions` are **space-separated strings**, not arrays. A dependency factory turns each into a per-route guard: ```python # auth.py (continued) def require_scope(required: str): def guard(claims: Claims) -> dict: granted = set( f"{claims.get('scope', '')} {claims.get('permissions', '')}".split() ) if required not in granted: raise HTTPException( status.HTTP_403_FORBIDDEN, detail="insufficient_scope", headers={ "WWW-Authenticate": ( f'Bearer error="insufficient_scope", scope="{required}"' ) }, ) return claims return guard ``` Trust `permissions` over `scope` when your API runs the `access_token_authz` dialect with `enforce_policies` on: that claim is the requested scopes **filtered against your API's permission catalog**, so a client cannot grant itself something you never declared. Merging both, as above, works with either dialect. --- ## 🚀 Step 3: Wire It Into Routes ```python # main.py from fastapi import Depends, FastAPI from auth import Claims, require_scope app = FastAPI() @app.get("/public") def public(): return {"ok": True} @app.get("/me") def me(claims: Claims): return {"sub": claims["sub"], "scope": claims.get("scope")} @app.get("/orders", dependencies=[Depends(require_scope("read:orders"))]) def list_orders(): return {"orders": []} @app.post("/orders", dependencies=[Depends(require_scope("write:orders"))]) def create_order(): return {"created": True} ``` Run it with `fastapi dev main.py`. To protect an entire router instead of route by route, hang the dependency on the router itself — it then applies to every path it contains: ```python from fastapi import APIRouter, Depends admin = APIRouter( prefix="/admin", dependencies=[Depends(require_scope("admin:all"))], ) ``` --- ## 🧪 Step 4: Test It Mint a real token with the [Client Credentials flow](../oauth-flows/client-credentials.md) — create a **Machine to Machine** [client](../clients.md) in the dashboard first: ```bash TOKEN=$(curl -s -X POST 'https://your-domain.auth.faable.link/oauth/token' \ -H 'content-type: application/json' \ -d '{ "grant_type": "client_credentials", "client_id": "...", "client_secret": "...", "audience": "https://api.myapp.com" }' | jq -r .access_token) curl http://localhost:8000/orders -H "authorization: Bearer $TOKEN" ``` Expected: `200` with the token, `401 missing_token` without it, `403 insufficient_scope` when the token lacks `read:orders`. --- ## 📘 Step 5: Add the Authorize Button to `/docs` FastAPI's interactive docs can drive the real login flow, so you can exercise protected endpoints from the browser with a **user** token instead of pasting one by hand: ```python from fastapi.security import OAuth2AuthorizationCodeBearer oauth2 = OAuth2AuthorizationCodeBearer( authorizationUrl=f"{ISSUER}/authorize", tokenUrl=f"{ISSUER}/oauth/token", scopes={"read:orders": "Read orders", "write:orders": "Create orders"}, ) app = FastAPI( swagger_ui_init_oauth={ "clientId": "YOUR_SPA_CLIENT_ID", "usePkceWithAuthorizationCodeGrant": True, # Faable only issues an API-scoped access token when an audience is asked for. "additionalQueryStringParams": {"audience": AUDIENCE}, } ) ``` Use `oauth2` in place of `HTTPBearer` in `verify_token` if you want the padlock wired to this scheme. Two things must line up in the dashboard, or the browser round-trip fails: - **Allowed Callback URLs** must include `http://localhost:8000/docs/oauth2-redirect`. - **Allowed Web Origins** must include `http://localhost:8000`, since Swagger UI performs the PKCE token exchange with a cross-origin `fetch`. Faable enforces **PKCE with S256** and advertises no other `code_challenge_method`, so leave `usePkceWithAuthorizationCodeGrant` on and use a public SPA client — never put a client secret in Swagger UI. --- ## ⚠️ Pitfalls - **Trailing slash on the issuer.** Faable's `iss` is `https://your-domain.auth.faable.link` with **no** trailing slash. Auth0-style config ported over with a trailing `/` rejects every token. - **Skipping `audience=`.** `jwt.decode` only validates `aud` when you pass `audience`. Without it, a token minted for a different API — or an OIDC-only token whose `aud` fell back to `/userinfo` — sails through. - **Treating `permissions` as a list.** It's a string. `"read:orders" in claims["permissions"]` is a substring match: it would accept `read:orders_archive`. Always `.split()` first. - **Validating against the `client_id`.** The **ID token's** `aud` is the client id; the **access token's** `aud` is the API identifier. Your API validates access tokens. - **Creating `PyJWKClient` inside the handler.** Build it once at module scope. A per-request client re-downloads the JWKS on every call and hands Faable a self-inflicted DDoS. - **`jwt.decode(..., options={"verify_signature": False})`.** Fine in a REPL to inspect a token; catastrophic in a dependency. There is no safe production use. --- ## ❓ FAQ ### Does my API call Faable on every request? No. The signature is verified locally against the cached JWKS. `PyJWKClient` refetches only when its cache expires or a token presents an unseen `kid` (key rotation). ### How do I know if the caller is a user or a machine? Look at `sub`: user tokens carry a `user_…` id, [machine-to-machine](../oauth-flows/client-credentials.md) tokens carry the client's `client_id`. ### Can I use python-jose or Authlib instead of PyJWT? Yes. Any JWT library with JWKS support works. Configure the same four checks: RS256, your tenant issuer without a trailing slash, your API identifier as audience, and expiry. `python-jose` is largely unmaintained — prefer PyJWT or Authlib for new code. ### How do I get the full user profile in my API? Access tokens carry identity claims, not a profile. Either enrich the token from an [Action](../extensibility/actions.md), or call [`/userinfo`](../oidc/userinfo.md) with the same token — that one **is** a network call, so cache it per `sub`. ### Does this work with Django, Flask or Litestar? Yes — the verifier is plain PyJWT. Only the dependency-injection wrapper is FastAPI-specific; swap it for a decorator or middleware in your framework. --- ## 🔗 Related - **[Validate Access Tokens](../validate-access-tokens.md)** — the same four checks in Node.js/Express, plus the anatomy of a Faable access token. - **[APIs](../apis.md)** — register your API, its identifier and its permission catalog. - **[Client Credentials Flow](../oauth-flows/client-credentials.md)** — how machine clients obtain these tokens. - **[Authorization Code Flow](../oauth-flows/authorization-code.md)** — how your frontend obtains a user token to send here. --- ## Faable Auth features Source: https://faable.com/docs/auth/features # Faable Auth features **Faable Auth is a managed, multi-tenant identity server built on OAuth 2.0 and OpenID Connect.** It covers the whole identity surface of a product: how people sign in, how your APIs authorize them, what happens over the account's lifetime, and how you extend and audit all of it — without you writing or operating any protocol plumbing. Every tenant runs on European infrastructure, and Auth is [included in every plan, starting on Free](pricing.mdx). New here? Start with [Get Started](get-started.md) or pick a [quickstart](quickstart). Evaluating against another vendor? See [Faable Auth vs Auth0, Clerk & Keycloak](compare.mdx). ## How users sign in A [connection](connections.md) is a source of users. Enable the ones you want on a [client](clients.md); your application code is identical whichever the user picks, because Faable normalizes every result into standard OAuth 2.0 / OIDC tokens. | Connection | What it gives users | | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Database** (`database`) | Email + password, with credentials stored and hashed by Faable. Self-service [signup](signup.md) from a browser-only app. | | **[Passwordless](passwordless.mdx)** | Magic link or 6-digit OTP by email. The account is created on first successful verification, already `email_verified`. | | **[Social](social)** | Google, GitHub, Microsoft (Entra ID) and Figma, with endpoints preconfigured — paste credentials and go. | | **Custom OAuth 2.0** (`custom`) | Any other provider: you supply authorize, token and userinfo URLs. [Facebook](social/facebook.md) is the worked example. | | **External OIDC issuer** (`oidc`) | Trust a third-party issuer's JWTs — GitHub Actions being the canonical case — for [Token Exchange](oauth-flows/token-exchange.mdx). Machine-to-machine, not a browser login. | Two details that save work: - **Universal Login** renders every enabled connection on one hosted screen, or you can send users straight to a provider with `connection_id=…` on `/authorize`. - **Shared Faable OAuth apps** for Google, GitHub and Microsoft let you switch a connection on before registering anything with the provider — the consent screen shows Faable until you paste your own credentials. ## Protocol surface ### OAuth 2.0 grants | Grant | Use it for | | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | [Authorization Code + PKCE](oauth-flows/authorization-code.mdx) | Browser and native apps. `S256` is enforced — no other challenge method is advertised. | | [Client Credentials](oauth-flows/client-credentials.mdx) | Backend services and workers, with no user involved. | | [Device Code](oauth-flows/device-code.mdx) (RFC 8628) | A `yourcli login` command, smart TVs, input-constrained devices. | | [Refresh Token](oauth-flows/refresh-token.mdx) | Keeping sessions alive without sending the user back through login. | | [Token Exchange](oauth-flows/token-exchange.mdx) (RFC 8693) | Keyless CI/CD: trade a GitHub Actions OIDC token for a Faable access token — no secrets in CI. | Not sure which one applies? [Which flow should I use?](oauth-flows) answers it in a table. ### OpenID Connect Faable Auth implements [OpenID Connect Core 1.0](oidc) on top of those grants: - **Discovery** at `/.well-known/openid-configuration`, so SDKs and third-party tools configure themselves from the tenant URL alone. - **ID tokens** signed with RS256, verifiable against the tenant's rotating **JWKS**. - **[UserInfo](oidc/userinfo.md)** — standard profile claims, scope-gated per OIDC §5.4. - **[RP-Initiated Logout](oidc/logout.md)** plus **Front-Channel Logout 1.0**: one `/logout` call clears the session at the tenant and notifies every other application signed into it. - **[Dynamic Client Registration](clients.md#dynamic-client-registration)** (RFC 7591) — what MCP clients, IDE integrations and OAuth debuggers expect when they onboard themselves. Because it's standards-first, anything that speaks OIDC plugs in. [Shopify Plus customer accounts](integrations/shopify.md), for instance, lets a store swap its built-in login for your tenant — configured entirely from the two dashboards, no code. ## Your APIs and permissions Register each backend as an [API](apis.md) (an OAuth resource server) and it gets its own audience identifier, its own permission catalog, and its own token rules: - **Audience-scoped tokens** — a token minted for `https://api.example.com` carries that `aud`, so every other service rejects it. - **Permissions** as `verb:resource` strings, with `enforce_policies` intersecting whatever a client requests against what the API actually defines. - **`access_token_authz` dialect** to get a structured `permissions` claim next to `scope`. - **Token lifetimes** per API, from 60 seconds to 30 days, and `allow_offline_access` to decide who may hold a refresh token. - **Roles and teams** — group users and grant permissions collectively rather than one by one. Your backend verifies all of this offline: signature against the JWKS, then issuer, audience and scope. [Validate Access Tokens](validate-access-tokens.mdx) ships the Express middleware. ## The account lifecycle Everything that happens to an identity after (and before) the first login is a supported flow, not something you rebuild: | Flow | What it does | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Login Experience](login-experience.md) | Which methods the hosted login screen offers and in what order, passkey sign-in, and a post-login invitation to create a passkey — per account, overridable per client. | | [Signup](signup.md) | Email + password registration from a browser-only app, and an `is_new_user` marker on the OAuth callback so you can branch into onboarding. | | [Change Email](change-email.md) | Ownership-verified email updates, with an optional double confirmation from the old address for high-risk accounts. | | [Team Invitations](team-invitations.md) | Invite by email: known users join the team directly, unknown addresses get a link that creates the account on first click. | | [Suspend Users](suspend-users.md) | Block a user across every login flow, token grant, session and management call — instantly, reversibly, without deleting data. | | [Logs](logs.md) | A queryable record of email deliveries, webhook attempts and authentication events, filterable with FaableQL. | ## Extensibility - **[Actions](extensibility/actions.md)** run your own JavaScript inside the flow, sandboxed. The `post-login` trigger fires after authentication and **before** tokens are issued, so an Action can enrich the token, deny access with `api.access.deny()`, or send the user through your own UI — terms of service, an MFA step, whatever the rule is — and pick the flow back up on the `continue` trigger. - **[Webhooks](extensibility/webhooks.md)** deliver `user.created`, `user.updated`, `user.deleted` and `auth.login` as signed HTTPS POSTs, with HMAC-SHA256 signatures and a timestamp for replay protection. ## Running the tenant - **[Faable Dashboard](https://dashboard.faable.com)** for connections, clients, users, teams, Actions and settings. - **Management API** — the same surface over HTTP, reached with a client-credentials token for the `faable:management:` audience. - **[`faable auth`](../cli.md#faable-auth)** in the CLI: `users`, `actions`, `clients` and `logs`, with `--query` filters, `--json` output for piping, and bulk suspend/reinstate from a filtered listing. - **[Audit logs](logs.md)** in the dashboard and over the API, or mirrored into your own warehouse through a webhook. ## Branding and delivery - **[Custom Domain](custom-domain.md)** — serve login from `auth.example.com`. Ownership is proven with a DNS TXT record and the TLS certificate is issued and renewed automatically. - **Transactional emails** — welcome, OTP and magic link, invitations, email-change confirmations — are rendered with your tenant's logo and colors, localized (English and Spanish today), and overridable per tenant from the dashboard. - **Delivery is auditable**: every send lands in [Logs](logs.md) with its `message_id`, so a "the email never arrived" report is a query, not a guess. ## SDKs and quickstarts | Package | For | | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | [`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js) | Browsers and React Native — PKCE, session state, token refresh. | | [`@faable/auth-helpers-react`](https://www.npmjs.com/package/@faable/auth-helpers-react) | React hooks for session and user state. | | [`@faable/auth-sdk`](https://www.npmjs.com/package/@faable/auth-sdk) | Node.js backends — token verification and the Management API. | Copy-paste [quickstarts](quickstart) exist for React, Next.js (client and server-side), Vue, Nuxt, SvelteKit, Angular, vanilla JavaScript, React Native and FastAPI. If you'd rather learn the model properly first, the [Auth Academy](academy) is a five-module course with a lab, an exam and a credential at the end. ## Plans and hosting Auth is part of the single Faable subscription, and the core — social login, database and passwordless connections, RBAC and teams — is available on **Free**. Webhooks and custom domains start at **Hobby**; audit logs and multiple auth accounts at **Pro**. Full detail, including MAU allowances, is on [Auth pricing](pricing.mdx). Your users' identities live on **our own hardware in a European datacenter** — not a US platform with an optional EU region. There is no non-EU region to fall back to. See [Security and Compliance](../platform/security-compliance.md) for the full posture, including the certifications we do not hold. ## Not available yet Being explicit beats discovering it mid-integration: - **SAML** and any pre-built enterprise SSO connector. Corporate IdPs connect over OAuth 2.0 / OIDC. - **SMS one-time codes.** Passwordless is email only. - **Sign in with Apple**, which requires a client secret signed as an ES256 JWT — not supported through custom OAuth connections yet. ## FAQ ### Is Faable Auth free? Yes — Faable Auth is included in every plan, starting on **Free**, with a fair-use monthly-active-user allowance and one Action per account. Paid plans raise the MAU allowance and unlock webhooks, custom domains, audit logs and multiple auth accounts. See [Auth pricing](pricing.mdx). ### Does Faable Auth support MFA or SAML? MFA yes, SAML not yet. [Two-step verification](mfa.md) is built in — authenticator app codes, security keys and passkeys — with a hosted enrolment and challenge, and a policy you set per account or per client. Passkeys can also replace the password entirely, and the hosted login can [invite users to create one](login-experience.md#inviting-users-to-create-a-passkey) right after they sign in. There is still no SAML connector; an enterprise IdP that speaks OIDC connects fine. ### Can I use Faable Auth without Faable Deploy? Yes. Faable Auth is a standalone identity server — your applications can run anywhere, on any cloud or on your own machines, and only need to reach the tenant over HTTPS. Running them on [Faable Deploy](../deploy/get-started.md) simply means one subscription and one support relationship. ### Where is my users' data hosted? On Faable's own hardware in a European datacenter, under European law. There is no non-EU region, so identities do not leave Europe. The details are in [Security and Compliance](../platform/security-compliance.md). ### Does my API have to call Faable on every request? No. Access tokens are JWTs signed with RS256, so your backend verifies them offline against the tenant's cached JWKS, then checks issuer, audience and scope. See [Validate Access Tokens](validate-access-tokens.mdx). ### How do I migrate from another identity provider? There are step-by-step guides for [Auth0](guides/migrate-from-auth0.mdx), [Clerk](guides/migrate-from-clerk.mdx) and [Keycloak](guides/migrate-from-keycloak.mdx), plus comparisons for [Firebase Authentication](compare-firebase-auth.mdx), [Supabase Auth](compare-supabase-auth.mdx), [AWS Cognito](compare-aws-cognito.mdx) and [Authelia](compare-authelia.mdx). In every case you export users from the old provider and import them over the Management API — password hashes included, so nobody is forced to reset, and social-only users re-link on their first login through the same provider. ## Start building 1. Create your account and auth tenant in the [Faable Dashboard](https://dashboard.faable.com). 2. Enable a [connection](connections.md) and register a [client](clients.md). 3. Wire it up with a [quickstart](quickstart). Create your auth tenant --- ## Auth pricing Source: https://faable.com/docs/auth/pricing # Auth pricing Faable Auth is part of the unified Faable subscription — and it is **included in every plan, starting on Free**. The platform fee and billing model are documented on the [Pricing](../platform/pricing.md) page; this page focuses on **what each tier includes for Auth specifically**. Start building for free ## What each plan includes for Auth | Plan | Auth entitlements | | --------- | --------------------------------------------------------------------------------------------------------------- | | **Free** | Faable Auth included · 1 auth account · fair-use MAU allowance · passwordless, social login, RBAC · 1 Action | | **Hobby** | 1 auth account · **250 MAU** · unlimited passwordless within MAU · unlimited Actions · Webhooks · Custom Domain | | **Pro** | Everything in Hobby · **unlimited auth accounts** · **10,000 MAU pooled** across accounts · **Audit logs** | ## Monthly Active Users (MAU) A **Monthly Active User** is any unique user who completes at least one successful login during a calendar month. The same user logging in from multiple devices in the same month counts once. - **Free** includes a fair-use MAU allowance while you build. If your project takes off, we'll reach out before applying any limit. - **Hobby** includes **250 MAU** on a single auth account. - **Pro** includes **10,000 MAU pooled** across every auth account on the subscription — useful when you run multiple products under one Faable plan. If you expect to exceed your plan's MAU allowance, [contact us](https://github.com/orgs/faablecloud/discussions) before you do — overage is handled on a case-by-case basis at the time of writing. ## Feature gating | Feature | Free | Hobby | Pro | | --------------------------------------- | :---------------: | :----------: | :----------: | | Social Login | ✅ | ✅ | ✅ | | Database (username/password) connection | ✅ | ✅ | ✅ | | Passwordless (OTP / Magic Link) | ✅ | ✅ | ✅ | | RBAC, Teams | ✅ | ✅ | ✅ | | Actions (PostLogin) | ✅ (1 Action max) | ✅ unlimited | ✅ unlimited | | Two-step verification (MFA, passkeys) | — | ✅ | ✅ | | Webhooks | — | ✅ | ✅ | | Custom Domain | — | ✅ | ✅ | | Audit logs | — | — | ✅ | | Multiple auth accounts | — | — | ✅ | ## Related - [Platform pricing](../platform/pricing.md) — tiers, platform fee, billing model. - [Deploy pricing](../deploy/pricing.md) — compute catalog, deployments per day, and bandwidth. - [Features overview](features.mdx) — what Faable Auth does, end-to-end. --- ## OAuth 2.0 Flows — Which One Should I Use? Source: https://faable.com/docs/auth/oauth-flows # OAuth 2.0 Flows Faable Auth implements the standard OAuth 2.0 / OpenID Connect grants. Which one you need depends on two questions: **is a user logging in?** and **can your app keep a secret?** ## Choosing a flow | Your situation | Use this flow | | :-------------------------------------------------------------------- | :------------------------------------------------------------------- | | A user logs into your web, SPA, or mobile app | [Authorization Code with PKCE](/auth/oauth-flows/authorization-code) | | A backend service calls an API — no user involved | [Client Credentials](/auth/oauth-flows/client-credentials) | | A user logs into a CLI tool, Smart TV, or IoT device | [Device Code](/auth/oauth-flows/device-code) | | You already have a session and want to renew it without re-login | [Refresh Token](/auth/oauth-flows/refresh-token) | | A CI job or workload has a JWT from a trusted issuer (GitHub Actions) | [Token Exchange](/auth/oauth-flows/token-exchange) | ## At a glance | Flow | User present | Needs `client_secret` | Returns | Typical client | | :---------------------------------------------------------------- | :-------------------- | :-------------------- | :-------------------------------------- | :--------------------------------- | | [Authorization Code + PKCE](/auth/oauth-flows/authorization-code) | Yes | No | Access + ID + Refresh token | SPA, mobile, server-side web | | [Client Credentials](/auth/oauth-flows/client-credentials) | No | **Yes** | Access token | Backend service, cron, CI | | [Device Code](/auth/oauth-flows/device-code) | Yes | No | Access + ID + Refresh token | CLI, Smart TV, IoT | | [Refresh Token](/auth/oauth-flows/refresh-token) | Already authenticated | No | New access + ID + rotated refresh token | Any client holding a refresh token | | [Token Exchange](/auth/oauth-flows/token-exchange) | No | No | Access token (30 min) | CI job, federated workload | All of them exchange credentials at the same endpoint — `POST /oauth/token` on your tenant domain — and return the same standard token response. In the browser, the [`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js) SDK picks and drives the right flow for you (PKCE + automatic refresh). ## FAQ ### Which OAuth flow should I use? If a user logs into your web, SPA, or mobile app, use Authorization Code with PKCE. If a backend service calls an API with no user involved, use Client Credentials. If the user logs in on a CLI, Smart TV, or IoT device, use Device Code. To renew an existing session without re-login, use the Refresh Token flow. ### Do I ever combine flows? Yes, all the time: a SPA uses **Authorization Code + PKCE** to log the user in and **Refresh Token** to stay logged in, while its backend uses **Client Credentials** to call other services. Same tenant, same users, same tokens. ### What about the Implicit flow or Resource Owner Password? They're legacy. OAuth 2.0 best practice (and the OAuth 2.1 draft) replaces the Implicit flow with Authorization Code + PKCE, and discourages sending user passwords through your app. Faable Auth steers you to the modern grants above. ### Where do I get the credentials each flow needs? Register a [Client](/auth/clients) in the [Faable Dashboard](https://dashboard.faable.com) — it gives you the `client_id` (and a `client_secret` for confidential clients). If your tokens target your own backend, also register an [API](/auth/apis) to get an `audience` and define permissions. ## Related - [Clients](/auth/clients) — register the application that runs the flow. - [APIs](/auth/apis) — register the backend the tokens are for. - [Connections](/auth/connections) — the login methods your users see. - [OAuth 2.0 grant types overview](https://oauth.net/2/grant-types/) — background reading. --- ## Authorization Code Flow with PKCE — Secure User Login Source: https://faable.com/docs/auth/oauth-flows/authorization-code # Authorization Code Flow with PKCE 🔐 The **OAuth 2.0 Authorization Code flow with PKCE** (Proof Key for Code Exchange) is the standard way to **log users in**. The user authenticates on your Faable Auth domain, your app receives a one-time code, and exchanges it for tokens — an **access token** to call your APIs, an **ID token** with the user's identity, and a **refresh token** to keep the session alive. PKCE adds a cryptographic proof that ties the code to the client that started the flow, so it's safe even for apps that **cannot keep a secret**: - ✅ Single Page Applications (React, Vue, Angular) - ✅ Native and mobile apps (iOS, Android, React Native) - ✅ Server-side web apps (Next.js, Express) For backend services calling APIs **without a user**, use the [Client Credentials flow](client-credentials.md) instead. --- ## 🔑 What is PKCE, and how does it work? **PKCE (Proof Key for Code Exchange, [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) — pronounced "pixy")** is an extension to the OAuth 2.0 Authorization Code flow that makes it safe for clients that **cannot keep a secret** — single-page apps, mobile, and native apps whose code ships to the user's device. It closes the **authorization-code interception attack**. Without PKCE, anything that captures the `code` on its way back to your app — a malicious app registered on the same mobile URL scheme, a leaky proxy, browser history — could exchange it for tokens. PKCE ties the code to the exact client that started the flow: 1. Your app generates a random secret, the **`code_verifier`**, and keeps it locally. 2. It sends only the **`code_challenge`** — `base64url(SHA-256(code_verifier))` — when it redirects to `/authorize`. The hash is one-way, so the challenge is useless to an attacker. 3. When exchanging the `code` for tokens, your app sends the original `code_verifier`. Faable recomputes the hash and **rejects the exchange unless it matches** the challenge from step 2. A stolen `code` is worthless without the `code_verifier`, which never left your app. That is why PKCE is now the recommended practice for **every** client type — including confidential, server-side apps. ### Authorization Code + PKCE vs other flows | Your app | Use | | :---------------------------------------------------------------- | :------------------------------------------ | | SPA, mobile, native, or server-side web app logging a **user** in | **Authorization Code + PKCE** (this page) | | Backend service / cron / CI calling an API with **no user** | [Client Credentials](client-credentials.md) | | CLI or device without a browser | [Device Code](device-code.md) | | Renewing an access token without re-login | [Refresh Token](refresh-token.md) | > The older **Implicit flow** (tokens returned in the URL fragment) is deprecated — never put tokens in URLs. Authorization Code + PKCE replaces it for public clients. --- ## 📸 How It Works ```mermaid sequenceDiagram participant User participant App as Your Application participant Faable as Faable Auth Note over App: Generates code_verifier,
derives code_challenge App->>Faable: Redirect to /authorize (code_challenge, state) User->>Faable: Logs in (password, passkey, Google, GitHub, ...) Faable-->>App: Redirect to your callback with ?code=...&state=... App->>Faable: POST /oauth/token (code + code_verifier) Faable-->>App: Access Token + ID Token + Refresh Token ``` The `code` is single-use and short-lived. Even if it's intercepted, it's useless without the `code_verifier` — which never left your app. --- ## ✅ Prerequisites From the [Faable Dashboard](https://dashboard.faable.com): 1. **Create a Client** for your application — this gives you the `client_id`. See [Clients](../clients.md). 2. **Add your callback URL** (e.g. `https://your-app.com/callback`) to the client's **Allowed Callback URLs**. Redirects to unlisted URLs are rejected. 3. **Enable at least one connection** on the client — email + password, passwordless, Google, GitHub… See [Connections](../connections.md). What the user sees on the login screen is decided by your tenant's [Login Flow](../login-flows.md). --- ## 🚀 Quick Start with `@faable/auth-js` The fastest path: our SDK generates the PKCE verifier, handles the redirect, exchanges the code, stores the session, and auto-refreshes tokens. ```ts import { createClient } from '@faable/auth-js' const auth = createClient({ domain: 'your-domain.auth.faable.link', clientId: 'YOUR_CLIENT_ID' }) // 1. Start the login — generates PKCE values and redirects to Faable const { error } = await auth.signInWithOauthConnection({ redirectTo: 'https://your-app.com/callback' // connection: 'google' // optional: skip the picker and go straight to one provider }) if (error) console.error(error) // e.g. redirectTo not in Allowed Callback URLs ``` On your callback page, complete the exchange and read the session: ```ts // 2. On /callback — exchanges the code for tokens and stores the session await auth.initialize() // 3. Anywhere in your app const { data } = await auth.getSession() console.log(data.session?.user) ``` That's it — the SDK also refreshes the access token transparently when it expires, using the [Refresh Token flow](refresh-token.md). > [!IMPORTANT] > The `redirectTo` URL must be listed in the client's **Allowed Callback URLs** in the dashboard, or the request will be rejected. > [!TIP] > **Sending the user back where they were?** Don't bake it into the callback (`redirectTo: '/callback?returnTo=/settings'`) — that makes every page a different callback URL to whitelist. Pass `returnTo` as its own option: the SDK stores it next to the PKCE verifier and hands it back to you after the exchange, while `redirectTo` stays one clean, registered URL. **Using a framework?** Follow a copy-paste quickstart instead: [React](../quickstart/react.md) · [Next.js](../quickstart/nextjs.md) · [Vue](../quickstart/vue.md) · [SvelteKit](../quickstart/sveltekit.md) · [Angular](../quickstart/angular.md) · [JavaScript (vanilla)](../quickstart/javascript.md) · [React Native](../quickstart/react-native.md). --- ## 🧩 How to Generate the `code_verifier` and `code_challenge` If you use `@faable/auth-js`, **skip this** — the SDK generates, stores, and sends these for you. Rolling your own? Here's the exact PKCE pair generation. The **`code_verifier`** is a high-entropy random string (43–128 chars from the unreserved set). The **`code_challenge`** is its SHA-256 hash, base64url-encoded — `code_challenge_method=S256`, the only method Faable supports. **Browser (Web Crypto API):** ```ts function base64url(bytes: ArrayBuffer): string { return btoa(String.fromCharCode(...new Uint8Array(bytes))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') } // code_verifier — 32 random bytes → base64url (~43 chars) const codeVerifier = base64url( crypto.getRandomValues(new Uint8Array(32)).buffer ) // code_challenge = base64url(SHA-256(code_verifier)) const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier) ) const codeChallenge = base64url(digest) ``` **Node.js:** ```ts import { createHash, randomBytes } from 'node:crypto' const codeVerifier = randomBytes(32).toString('base64url') const codeChallenge = createHash('sha256') .update(codeVerifier) .digest('base64url') ``` Keep the `code_verifier` until the callback — it must survive the redirect (e.g. `sessionStorage` in a SPA). Send the `code_challenge` on `/authorize`, then send the original `code_verifier` back on the token exchange. --- ## 🛠️ Step-by-Step over HTTP Implementing it yourself, or curious what the SDK does under the hood? The whole flow is two requests. ### Step 1: Redirect the User to `/authorize` Generate a random `code_verifier`, derive the challenge as `base64url(sha256(code_verifier))`, and redirect the browser to: - **Endpoint:** - **Method:** `GET` (browser redirect) ``` https://your-domain.auth.faable.link/authorize ?response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=https://your-app.com/callback &scope=openid profile email &state=RANDOM_OPAQUE_VALUE &code_challenge=BASE64URL_SHA256_OF_VERIFIER &code_challenge_method=S256 &audience=https://api.myapp.com ``` | Parameter | Required | Description | | :---------------------- | :---------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `response_type` | Yes | Must be `code` (it is also the default if omitted). | | `client_id` | Yes | Your application's Client ID. | | `redirect_uri` | Yes | Where to send the user after login. Must be in the client's **Allowed Callback URLs**. | | `scope` | Recommended | Space-separated. Use `openid profile email` to get an ID token with the user's profile. Add your API's scopes (e.g. `read:orders`) to have them in the access token. | | `state` | Recommended | Opaque random value echoed back on the callback. **Verify it matches** to prevent CSRF. | | `code_challenge` | Recommended | `base64url(sha256(code_verifier))`. Enables PKCE. If you send it, `code_challenge_method` becomes required. | | `code_challenge_method` | Recommended | Only `S256` is supported; `plain` is rejected. | | `audience` | No | Identifier of the [API](../apis.md) the access token should target — sets its `aud` claim. Without it the access token is only good for [UserInfo](../oidc/userinfo.md), and an API that validates `aud` will reject it. | | `resource` | No | [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) alias of `audience`, for libraries that use the standard name. Must be a valid URI. | | `connection` | No | Which [connection](../connections.md) to use (e.g. `google`, or a connection id) — skips the picker and goes straight to that provider. `connection_id` is accepted as an alias. Defaults to the client's default connection. | | `nonce` | No | OIDC replay protection — echoed in the issued `id_token`. | | `prompt` | No | Controls the login UI — see [Controlling the prompt](#controlling-the-prompt). | ### Step 2: The User Logs In Faable shows your tenant's login screen and the user authenticates with any connection you've enabled — email/password, passkey, passwordless OTP, Google, GitHub, or a custom provider. The exact sequence of screens (email first, second factor, "remember me", passkey offer) is your tenant's [Login Flow](../login-flows.md); branding lives in [Login Experience](../login-experience.md). Your app doesn't change for any of it. If the user already has an active session on your Faable domain (they logged in to another of your apps, or ticked "remember me"), this step is skipped and the redirect happens immediately — that's single sign-on. Send `prompt=login` to force the screen anyway. ### Step 3: Receive the Code on Your Callback On success, the browser lands back on your app: ``` https://your-app.com/callback?code=AUTHORIZATION_CODE&state=RANDOM_OPAQUE_VALUE ``` Verify that `state` matches the value you sent in Step 1. On failure (e.g. the user cancels, or `prompt=none` needed interaction), the redirect carries an error instead: ``` https://your-app.com/callback?error=access_denied&error_description=...&state=... ``` ### Step 4: Exchange the Code for Tokens From your app, `POST` the code to the token endpoint together with the original `code_verifier`: - **Endpoint:** - **Content-Type:** `application/x-www-form-urlencoded` or `application/json` ```bash curl --request POST \ --url 'https://your-domain.auth.faable.link/oauth/token' \ --header 'content-type: application/x-www-form-urlencoded' \ --data 'grant_type=authorization_code' \ --data 'client_id=YOUR_CLIENT_ID' \ --data 'code=AUTHORIZATION_CODE' \ --data 'code_verifier=ORIGINAL_CODE_VERIFIER' \ --data 'redirect_uri=https://your-app.com/callback' ``` | Field | Required | Description | | :-------------- | :------- | :--------------------------------------------------------------------------------------------------- | | `grant_type` | Yes | `authorization_code`. | | `client_id` | Yes | The **same** client that started the flow — the code is bound to it. | | `code` | Yes | The value from the callback. | | `code_verifier` | Yes\* | The original verifier. \*Required whenever you sent a `code_challenge` in Step 1 — which you should. | | `redirect_uri` | Yes | The same `redirect_uri` as in Step 1. | No `client_secret` is needed: PKCE is the proof. Calling from the browser? The request must come from an origin listed in the client's **Allowed Web Origins**, or the CORS preflight fails. ### Response ```json { "access_token": "eyJhbGciOiJSUzI1NiIs...", "id_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "v1.MRjD...", "token_type": "Bearer", "expires_in": 86400 } ``` | Field | Description | | :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `access_token` | Signed JWT (RS256) for calling your APIs. Send it as `Authorization: Bearer ` — see [Validate Access Tokens](../validate-access-tokens.md) for the server side. | | `id_token` | OIDC JWT with the user's identity claims (`sub`, `email`, `name`, ...). Its `aud` is your `client_id`. | | `refresh_token` | Use it to obtain new access tokens without re-authenticating — see [Refresh Token flow](refresh-token.md). Always returned by this flow; no `offline_access` scope needed. | | `expires_in` | Access token lifetime in **seconds**, controlled by the target API's `token_lifetime` (default: 86400 = 24 h). | > [!NOTE] > The authorization `code` is **single-use** — it is consumed and invalidated on the first exchange, and it expires on its own after an hour. A second exchange with the same code fails with `state not found`. --- ## 🎛️ Controlling the Prompt The optional `prompt` parameter on `/authorize` controls whether the user sees the login UI. It accepts a space-separated list: | Value | Behavior | | :--------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `none` | **SSO probe.** No UI is ever displayed; the request errors if interaction would be needed. Must be the only value. Useful for silent re-authentication from a SPA. | | `login` | Forces the user to re-authenticate, even with an active session. | | `consent` | Forces the consent step before issuing tokens. Currently behaves like `login` (a dedicated consent screen is on the roadmap). | | `select_account` | Accepted for compatibility but currently ignored. | Unknown prompt values are accepted silently. Most applications can leave `prompt` unset. --- ## ⚠️ Common Errors Errors on `/authorize` are returned to your `redirect_uri` as `?error=...&error_description=...` query parameters, except for malformed requests, which get a `400` before any redirect. Errors on the token exchange return an OAuth 2.0 error body (`{ "error": "...", "error_description": "..." }`). These are the messages the server actually returns: | Where | Error | Cause / Fix | | :----------- | :---------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/authorize` | Callback URL rejected | The `redirect_uri` is not in the client's **Allowed Callback URLs**. Add it in the dashboard — exact match, scheme and path included. | | `/authorize` | `Missing code_challenge_method` | You sent `code_challenge` without the method. Add `code_challenge_method=S256`. | | `/authorize` | `Only S256 method is supported` | `code_challenge_method=plain`. Hash the verifier with SHA-256 and send `S256`. | | `/authorize` | `login_required` with `prompt=none` | No active session and interaction was forbidden. Fall back to an interactive login. | | `/authorize` | No database connection available | The client has no enabled connection and no default. Enable one under [Connections](../connections.md) or pass `connection`. | | Token `400` | `Missing code` | The `code` parameter is absent — usually the callback handler read the wrong query key. | | Token `400` | `state not found` | The code was already used, expired (1 hour), or was issued by a different client. Restart the flow to get a fresh one. | | Token `400` | `Code Verifier is not valid` | The `code_verifier` doesn't hash to the `code_challenge` sent at `/authorize`. Use the exact original value — a common cause is regenerating it on the callback page. | | Token `400` | `No challenge present in state` | You sent a `code_verifier` but no `code_challenge` at `/authorize`. Send both, or neither. | | Token `401` | `Bad client_id` | The code was issued to a different client. Use the same `client_id` in both steps. | | Token (CORS) | Preflight fails from the browser | Your app's origin isn't in the client's **Allowed Web Origins**. Add it. | --- ## 🔒 Security Best Practices - **Always use PKCE**, even in server-side apps. It's the current OAuth 2.0 best practice for every client type. - **Always send and verify `state`.** It's your CSRF protection on the callback. - **Always send `audience`** when the access token is for your API. Your API should reject tokens whose `aud` is the UserInfo URL — see [Validate Access Tokens](../validate-access-tokens.md). - **Never put tokens in URLs.** The code flow exists precisely so tokens travel in a POST response body, not in the address bar or browser history. - **Register exact callback URLs.** Prefer full paths (`https://app.com/auth/callback`) over broad patterns, and keep the list short. - **Let the SDK store the session.** `@faable/auth-js` handles storage, rotation, and cross-tab sync for you. --- ## ❓ FAQ ### Do I need a `client_secret` for this flow? No. PKCE replaces the secret for public clients (SPAs, mobile). That's the point: the flow is secure without shipping any secret to the browser. ### What is PKCE, in one sentence? Your app invents a random secret (`code_verifier`), sends only its hash when the flow starts, and reveals the secret when exchanging the code — proving both requests came from the same app. ### Does this flow return a refresh token? Yes, always — you don't need to request an `offline_access` scope. Use it with the [Refresh Token flow](refresh-token.md) to renew access tokens silently. The `@faable/auth-js` SDK does this automatically. ### How do users actually log in? With whatever [connections](../connections.md) you enable on the client: email/password, passkeys, passwordless OTP, Google, GitHub, or custom OIDC providers. The screens they go through — email first, second factor, remember me — are configured per tenant in [Login Flows](../login-flows.md). The OAuth flow your app implements is identical for all of them. ### When should I use Client Credentials instead? When there is no user — a backend service, cron job, or CI pipeline calling an API. See [Client Credentials](client-credentials.md). ### How do I generate a `code_verifier` and `code_challenge`? The verifier is a random high-entropy string; the challenge is `base64url(SHA-256(verifier))`. See [How to Generate the code_verifier and code_challenge](#how-to-generate-the-code_verifier-and-code_challenge) for copy-paste browser and Node snippets — or let `@faable/auth-js` do it for you. ### Is PKCE required? For public clients (SPA, mobile, native) it's mandatory in practice — the flow is unsafe without it. For confidential server-side clients it's strongly recommended and the current OAuth 2.0 best practice. Faable supports it for every client type. ### What is `code_challenge_method=S256`? It tells the server the challenge is the SHA-256 hash of the verifier (not the plaintext). `S256` is the only method Faable accepts — plain challenges are rejected. ### Why does my API reject the access token from this flow? Almost always because the `/authorize` request had no `audience`. Without it the access token's `aud` is your tenant's `/userinfo` URL, and any API that validates the audience (as it should) rejects it. Add `audience=` to Step 1. ### Can I skip the login screen for a specific provider? Yes — pass `connection=google` (or the connection's id) on `/authorize`, or `connection: 'google'` to the SDK. The user goes straight to that provider instead of the picker. --- ## 🔗 Related - **[@faable/auth-js](https://www.npmjs.com/package/@faable/auth-js)** — official JavaScript/TypeScript SDK; PKCE handled for you. - **[Validate Access Tokens](../validate-access-tokens.md)** — what your API does with the token this flow returns. - **[Refresh Token Flow](refresh-token.md)** — silent session renewal. - **[Login Flows](../login-flows.md)** — configure what the user sees between Step 1 and Step 3. - **[Clients](../clients.md)** — register your app and configure callback URLs and web origins. - **[Connections](../connections.md)** — enable social, passwordless, and enterprise login methods. - **[RFC 6749 §4.1 — Authorization Code Grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1)** — the official OAuth 2.0 standard. - **[RFC 7636 — Proof Key for Code Exchange](https://datatracker.ietf.org/doc/html/rfc7636)** — the PKCE specification. --- ## Client Credentials Flow — Machine-to-Machine (M2M) Authentication Source: https://faable.com/docs/auth/oauth-flows/client-credentials # Client Credentials Flow 🤖 The **OAuth 2.0 Client Credentials flow** is the standard way for a backend service to get an access token **without a human user involved**. Your service authenticates with its `client_id` and `client_secret`, receives a signed JWT access token, and uses it to call your protected API. Use it for **machine-to-machine (M2M)** communication: - ✅ Microservices calling each other - ✅ Cron jobs, background workers, and daemons - ✅ CLIs and CI/CD pipelines calling your API - ✅ Third-party servers integrating with your platform Do **not** use it in browsers or mobile apps — it requires a `client_secret`, which can never be shipped to a device you don't control. For user-facing apps, use the [Authorization Code flow with PKCE](authorization-code.md) with our [`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js) SDK. --- ## 📸 How It Works ```mermaid sequenceDiagram participant App as Your Backend Service participant Faable as Faable Auth participant API as Your Protected API App->>Faable: POST /oauth/token (client_id + client_secret) Faable-->>App: Access Token (JWT, expires_in) App->>API: GET /resource (Authorization: Bearer ) API-->>App: 200 OK — protected data ``` One round trip. No redirects, no login screens, no consent prompts. --- ## ✅ Prerequisites From the [Faable Dashboard](https://dashboard.faable.com) you need: 1. **A Machine-to-Machine Client** — this gives you the `client_id` and `client_secret`. See [Clients](../clients.md). 2. **A registered API** (the resource server your service will call) — its `identifier` is the `audience` you request tokens for, and it defines the permissions (scopes) available. See [APIs](../apis.md). --- ## 🛠️ Step 1: Request an Access Token Make a `POST` request to your tenant's token endpoint: - **Endpoint:** - **Method:** `POST` - **Content-Type:** `application/x-www-form-urlencoded` or `application/json` — both are accepted. ### Request Parameters | Parameter | Required | Description | | :-------------- | :---------- | :----------------------------------------------------------------------------------------------------------------- | | `grant_type` | Yes | Must be `client_credentials`. | | `client_id` | Yes | Your application's Client ID. | | `client_secret` | Yes | Your application's Client Secret. Keep it server-side only. | | `audience` | Recommended | The `identifier` of the [API](../apis.md) you want to call. Sets the token's `aud` claim and its lifetime. | | `scope` | No | Space-separated list of permissions to request (e.g. `read:users write:users`). Must be defined on the target API. | > [!TIP] > Instead of sending `client_id` and `client_secret` in the body, you can send them as an HTTP Basic `Authorization` header (`client_secret_basic`), as described in RFC 6749. Both methods work identically. ### Example with `curl` ```bash curl --request POST \ --url 'https://your-domain.auth.faable.link/oauth/token' \ --header 'content-type: application/x-www-form-urlencoded' \ --data 'grant_type=client_credentials' \ --data 'client_id=YOUR_CLIENT_ID' \ --data 'client_secret=YOUR_CLIENT_SECRET' \ --data 'audience=https://api.example.com' \ --data 'scope=read:users' ``` ### Response ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 86400, "scope": "read:users" } ``` | Field | Description | | :------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | | `access_token` | A signed JWT (RS256). Its `aud` claim matches the requested `audience`. | | `token_type` | Always `Bearer`. | | `expires_in` | Token lifetime in **seconds**, controlled by the target API's `token_lifetime` setting (default: 86400 = 24 h). | | `scope` | The scopes actually granted. When the API has `enforce_policies` enabled, only permissions defined on the API are granted — anything else is dropped. | > [!NOTE] > The Client Credentials flow never returns a `refresh_token` — there is no user session to maintain. When the token expires, simply request a new one. --- ## 🚀 Step 2: Call Your API with the Token Send the token in the `Authorization` header of every request to your API: ```bash curl --request GET \ --url 'https://api.example.com/users' \ --header 'authorization: Bearer eyJhbGciOiJSUzI1NiIs...' ``` Your API validates the token by checking its **signature** against your tenant's public keys (), its **`aud` claim** against the API's identifier, and its **`scope`** against the permission the endpoint requires. See [Validate Access Tokens](../validate-access-tokens.md) for ready-to-use Express middleware, and [APIs](../apis.md) for the full model. --- ## 💻 Complete Node.js Example A production-ready pattern: fetch the token once, cache it, and refresh it slightly before it expires. ```ts const AUTH_DOMAIN = 'https://your-domain.auth.faable.link' let cached: { token: string; expiresAt: number } | null = null async function getAccessToken(): Promise { // Reuse the cached token, refreshing 60s before expiry if (cached && cached.expiresAt > Date.now() + 60_000) { return cached.token } const res = await fetch(`${AUTH_DOMAIN}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.FAABLE_CLIENT_ID!, client_secret: process.env.FAABLE_CLIENT_SECRET!, audience: 'https://api.example.com', scope: 'read:users' }) }) if (!res.ok) { const err = await res.json() throw new Error(`Token request failed: ${err.error}`) } const { access_token, expires_in } = await res.json() cached = { token: access_token, expiresAt: Date.now() + expires_in * 1000 } return access_token } // Use it const token = await getAccessToken() const users = await fetch('https://api.example.com/users', { headers: { authorization: `Bearer ${token}` } }) ``` > [!IMPORTANT] > **Always cache the token until it expires.** Requesting a new token on every API call adds latency, and the token endpoint is rate-limited per client — a tight token loop will get throttled with `429` responses. --- ## ⚠️ Common Errors Errors follow the standard OAuth 2.0 error format (RFC 6749 §5.2): `{ "error": "...", "error_description": "..." }`. | Status | Cause | Fix | | :----- | :----------------------------------------------------- | :--------------------------------------------------------------------------- | | `400` | Missing `grant_type`, `client_id`, or `client_secret`. | Check all required parameters are present in the request body. | | `401` | Bad credentials. | The `client_secret` doesn't match. Verify it, or rotate it in the dashboard. | | `404` | Client not found. | The `client_id` doesn't exist in this tenant. Check the ID and the domain. | | `429` | Rate limit exceeded. | You're requesting tokens too often. Cache the token (see example above). | --- ## 🔒 Security Best Practices - **Never use this flow in a browser or mobile app.** The `client_secret` must only exist on servers you control. - **Store the secret in an environment variable or secret manager** — never commit it to source control. - **Request only the scopes you need.** Narrow tokens limit the blast radius if one leaks. - **Rotate the secret** from the dashboard if you suspect it has been exposed. Rotation invalidates the old secret immediately. - **Use one client per service.** Separate credentials make audit logs meaningful and let you revoke a single service without breaking the rest. --- ## ❓ FAQ ### How long does the access token last? It's defined by the target API's `token_lifetime` setting — from 60 seconds up to 30 days, with a default of 24 hours. ### Does this flow return a refresh token? No. There's no user session to refresh. When the token expires, request a new one with the same credentials. ### What's the difference between Client Credentials and Authorization Code? Authorization Code authenticates a **user** (login screen, redirects, consent). Client Credentials authenticates an **application** — no user, no UI, one HTTP request. ### Can I use this flow to call the Faable Management API? Yes — create an M2M client, and request a token with the Management API's audience. This is how you automate user management, client provisioning, and more from your backend. --- ## 🔗 Related - **[Clients](../clients.md)** — create an M2M client and get your credentials. - **[APIs](../apis.md)** — register your API, define its audience and permissions. - **[Authorization Code Flow](authorization-code.md)** — for user-facing applications, with the [@faable/auth-js](https://www.npmjs.com/package/@faable/auth-js) SDK. - **[API Reference](https://faable.auth.faable.link/docs/json)** — full OpenAPI specification. - **[RFC 6749 §4.4 — Client Credentials Grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)** — the official OAuth 2.0 standard. --- ## Add User Login to Your CLI — OAuth 2.0 Device Flow Source: https://faable.com/docs/auth/oauth-flows/device-code # Device Code Flow: Login for Your CLI 💻 You're building a CLI tool and you want a `yourcli login` command that works like the ones from GitHub, Vercel, or Fly: the terminal shows a short code, the user confirms it in the browser, and the CLI is authenticated — no passwords typed into the terminal, no secrets shipped in your binary. That's the **OAuth 2.0 Device Authorization Grant** (RFC 8628), and Faable Auth gives it to you out of the box. It works anywhere a browser isn't available: - ✅ **CLI tools** — `yourcli login` (the main event; this is exactly how Faable's own `faable login` works) - ✅ Smart TVs and set-top boxes - ✅ IoT devices, kiosks, and printers For apps that **do** run in a browser or on mobile, use the [Authorization Code flow](authorization-code.md) with the **`@faable/auth-js`** SDK instead. --- ## 📸 How It Works ```mermaid sequenceDiagram participant CLI as Your CLI participant Faable as Faable Auth participant User as User's Browser CLI->>Faable: POST /oauth/device/code (client_id) Faable-->>CLI: user_code + device_code + verification_uri Note over CLI: Prints "Confirm code ABCD-1234"
and opens the browser CLI->>Faable: Polls POST /oauth/token every 5s User->>Faable: Logs in & approves the device Faable-->>CLI: Access Token + ID Token + Refresh Token ``` The terminal never sees the user's credentials — the login happens entirely in the user's browser, with whatever [connections](../connections.md) you've enabled (Google, GitHub, email/password, passwordless...). --- ## ✅ Prerequisites Create a **Client** for your CLI in the [Faable Dashboard](https://dashboard.faable.com) — you only need its `client_id`. CLIs are public clients: **no `client_secret` is involved**, so there's nothing sensitive to embed in your binary. See [Clients](../clients.md). --- ## 🛠️ Step 1: Request the Codes When the user runs `yourcli login`, ask Faable for a code pair: - **Endpoint:** - **Method:** `POST` - **Content-Type:** `application/x-www-form-urlencoded` or `application/json` ### Request Parameters | Parameter | Required | Description | | :------------ | :------- | :---------------------------------------------------------------------------------------------------------------------------------- | | `client_id` | Yes | Your application's Client ID. | | `scope` | No | Space-separated. Use `openid profile email offline_access` to also receive a refresh token so the user stays logged in across runs. | | `audience` | No | Identifier of the [API](../apis.md) the access token should target. | | `device_name` | No | Human-friendly name (e.g. `marcs-mbp (macOS)`) shown on the approval page so the user recognizes which device is asking. | ```bash curl --request POST \ --url 'https://your-domain.auth.faable.link/oauth/device/code' \ --header 'content-type: application/json' \ --data '{ "client_id": "YOUR_CLIENT_ID", "scope": "openid profile email offline_access", "device_name": "marcs-mbp (macOS)" }' ``` ### Response ```json { "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", "user_code": "ABCD-1234", "verification_uri": "https://your-domain.auth.faable.link/activate", "verification_uri_complete": "https://your-domain.auth.faable.link/activate?user_code=ABCD-1234", "expires_in": 300, "interval": 5 } ``` | Field | Description | | :-------------------------- | :----------------------------------------------------------------------------------------------------- | | `device_code` | The CLI's private handle for polling. **Never display it.** | | `user_code` | The short code to show the user in the terminal. | | `verification_uri` | The URL (on your tenant domain) where the user enters the code. | | `verification_uri_complete` | Same URL with the code pre-filled — open it in the default browser, or render it as a QR code on a TV. | | `expires_in` | The codes expire in **300 seconds (5 minutes)**. Start over if the user takes longer. | | `interval` | Minimum seconds between polls: **5**. Polling faster returns `slow_down`. | ### Step 2: Show the Code and Open the Browser Print the `user_code` and open `verification_uri_complete` in the default browser (fall back to printing the URL when there's no display, e.g. over SSH). The user logs in and approves — the approval page shows your `device_name`. ``` $ yourcli login Opening https://your-domain.auth.faable.link/activate?user_code=ABCD-1234 Confirm this code in your browser: ABCD-1234 Waiting for approval... ``` ### Step 3: Poll for the Token While the user approves, poll the token endpoint every `interval` seconds: - **Endpoint:** - **Method:** `POST` | Parameter | Required | Description | | :------------ | :------- | :------------------------------------------------------ | | `grant_type` | Yes | Must be `urn:ietf:params:oauth:grant-type:device_code`. | | `client_id` | Yes | The same Client ID used in Step 1. | | `device_code` | Yes | The `device_code` from Step 1. | ```bash curl --request POST \ --url 'https://your-domain.auth.faable.link/oauth/token' \ --header 'content-type: application/json' \ --data '{ "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "client_id": "YOUR_CLIENT_ID", "device_code": "THE_DEVICE_CODE" }' ``` ### Polling Responses | Scenario | Status | `error` | What to do | | :--------------- | :----- | :---------------------- | :------------------------------------------------------------- | | Waiting for user | `400` | `authorization_pending` | Keep polling at the `interval`. | | Polling too fast | `400` | `slow_down` | You polled less than 5 s after the previous attempt. Back off. | | Code expired | `400` | `expired_token` | The 5-minute window passed. Request a new code pair (Step 1). | | Wrong client | `401` | `invalid_client` | The `client_id` doesn't match the one that requested the code. | | **Approved** ✅ | `200` | — | Token response — stop polling. | ### Success Response ```json { "access_token": "eyJhbGciOiJSUzI1NiIs...", "id_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "v1.MRjD...", "token_type": "Bearer", "expires_in": 86400 } ``` Your CLI gets the full token set: an **access token** to call your API on behalf of the user, an **ID token** with their identity (handy for `yourcli whoami`), and a **refresh token** to keep them logged in across runs via the [Refresh Token flow](refresh-token.md). > [!NOTE] > The `device_code` is **single-use**: it is deleted the moment tokens are issued. Any further poll with it returns `expired_token`. --- ## 💻 Complete Example: a `login` Command in Node.js The full loop — request codes, open the browser, poll, and land on a session. This mirrors what `faable login` does in production: ```ts import os from 'os' const AUTH_DOMAIN = 'https://your-domain.auth.faable.link' const CLIENT_ID = 'YOUR_CLIENT_ID' async function loginCommand() { // Step 1: request the code pair const codeRes = await fetch(`${AUTH_DOMAIN}/oauth/device/code`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ client_id: CLIENT_ID, scope: 'openid profile email offline_access', device_name: `${os.hostname()} (${process.platform})` }) }) const { device_code, user_code, verification_uri_complete, interval } = await codeRes.json() // Step 2: show the code and let the user approve in the browser console.log(`Open ${verification_uri_complete}`) console.log(`and confirm the code: ${user_code}`) // Step 3: poll until approved while (true) { await new Promise(r => setTimeout(r, interval * 1000)) const tokenRes = await fetch(`${AUTH_DOMAIN}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', client_id: CLIENT_ID, device_code }) }) if (tokenRes.ok) return tokenRes.json() // { access_token, id_token, refresh_token, ... } const { error } = await tokenRes.json() if (error === 'authorization_pending') continue if (error === 'slow_down') continue // next loop already waits `interval` throw new Error(`Login failed: ${error}`) // expired_token, invalid_client } } const { access_token, refresh_token } = await loginCommand() console.log('You are logged in!') ``` > [!TIP] > Persist the `refresh_token` (OS keychain, or a config file like `~/.yourcli/credentials` with `0600` permissions) and use the [Refresh Token flow](refresh-token.md) on the next run — your users log in once, not on every invocation. --- ## ❓ FAQ ### Do I need a `client_secret` in my CLI? No. CLIs are public clients — the flow is designed to work with only the `client_id`, so there's nothing secret to embed (or leak) in your distributed binary. ### How do I keep the user logged in between CLI runs? Request the `offline_access` scope, store the returned `refresh_token` securely, and exchange it for a fresh access token on startup with the [Refresh Token flow](refresh-token.md). ### How long does the user have to approve? 5 minutes (`expires_in: 300`). After that, request a new code pair and show a fresh `user_code`. ### How does the user log in on the approval page? With any [connection](../connections.md) you've enabled — email/password, passwordless OTP, Google, GitHub, or custom providers. Your CLI inherits your entire login stack for free. ### Can I use `@faable/auth-js` for this flow? The device flow is plain HTTP by design (it targets environments where a browser SDK doesn't apply), so you implement the loop above directly. Use `@faable/auth-js` for your browser and mobile apps — same tenant, same users, same tokens. ### Does this also work for Smart TVs and IoT? Yes — the flow is identical. Render `verification_uri_complete` as a QR code instead of opening a browser, and the user approves from their phone. --- ## 🔗 Related - **[Refresh Token Flow](refresh-token.md)** — keep the CLI session alive after the first login. - **[Authorization Code Flow](authorization-code.md)** — login for apps that do have a browser, via `@faable/auth-js`. - **[Clients](../clients.md)** — register your CLI application. - **[Connections](../connections.md)** — the login methods available to your users. - **[RFC 8628 — OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628)** — the official standard. --- ## Refresh Token Flow — Keep Sessions Alive Without Re-Login Source: https://faable.com/docs/auth/oauth-flows/refresh-token # Refresh Token Flow 🔄 Access tokens are short-lived on purpose — a leaked token expires quickly. The **Refresh Token flow** lets your application obtain a fresh access token **without sending the user back through login**, keeping sessions seamless while the tokens stay short-lived. You receive a refresh token when a user authenticates with the [Authorization Code flow](authorization-code.md) or the [Device Code flow](device-code.md). Store it, and exchange it whenever the access token expires. > [!TIP] > If you use **`@faable/auth-js`**, you can skip this page entirely: the SDK refreshes tokens automatically in the background. Read on if you're curious or implementing the flow yourself. --- ## 📸 How It Works ```mermaid sequenceDiagram participant App as Your Application participant Faable as Faable Auth Note over App: Access Token expires App->>Faable: POST /oauth/token (grant_type=refresh_token) Faable-->>App: New Access Token + ID Token + rotated Refresh Token ``` Every refresh returns a **new refresh token** (rotation). Always store the latest one and use it for the next refresh. --- ## 🚀 Automatic Refresh with `@faable/auth-js` The SDK manages the whole lifecycle: it stores the session, runs a background ticker, and exchanges the refresh token before the access token expires — across tabs, transparently. ```ts import { createClient } from '@faable/auth-js' const auth = createClient({ domain: 'your-domain.auth.faable.link', clientId: 'YOUR_CLIENT_ID' }) // That's it — sessions refresh automatically in the background. const { data } = await auth.getSession() ``` ### Forcing a Manual Refresh Need a guaranteed-fresh token — for example right after a server-side change to the user's claims? Call `refreshSession()`: ```ts const { data, error } = await auth.refreshSession() if (!error) { console.log('New access token:', data.session.access_token) } ``` --- ## 🛠️ Refreshing over HTTP Implementing it yourself (a backend, a CLI, a non-JS stack)? It's a single request. - **Endpoint:** - **Method:** `POST` - **Content-Type:** `application/x-www-form-urlencoded` or `application/json` ### Request Parameters | Parameter | Required | Description | | :-------------- | :------- | :---------------------------------------------------------------------------------------------------------------------------- | | `grant_type` | Yes | Must be `refresh_token`. | | `client_id` | Yes | Your application's Client ID. | | `refresh_token` | Yes | The most recent refresh token you received. | | `scope` | No | Space-separated. May only **narrow** the original grant — scopes not present in the original token are dropped (RFC 6749 §6). | The token's `audience` is preserved automatically from the original grant — you don't (and can't) change it on refresh. ### Example with `curl` ```bash curl --request POST \ --url 'https://your-domain.auth.faable.link/oauth/token' \ --header 'content-type: application/x-www-form-urlencoded' \ --data 'grant_type=refresh_token' \ --data 'client_id=YOUR_CLIENT_ID' \ --data 'refresh_token=YOUR_REFRESH_TOKEN' ``` ### Response ```json { "access_token": "eyJhbGciOiJSUzI1NiIs...", "id_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "v1.NEW_ROTATED_TOKEN...", "token_type": "Bearer", "expires_in": 86400 } ``` | Field | Description | | :-------------- | :---------------------------------------------------------------------------------------------------------------- | | `access_token` | Fresh JWT, same `aud` as the original grant. | | `id_token` | Refreshed OIDC identity token. Keeps the original `nonce`, `auth_time`, and session id (`sid`) per the OIDC spec. | | `refresh_token` | **A new, rotated refresh token.** Replace the stored one with this value. | | `expires_in` | New access token lifetime in **seconds** (from the target API's `token_lifetime`, default 86400 = 24 h). | --- ## ⚠️ Common Errors Errors use the standard OAuth 2.0 format: `{ "error": "...", "error_description": "..." }`. | Status | Error | Cause / Fix | | :----- | :---------------------- | :--------------------------------------------------------------------------------------------------------------------------------- | | `400` | Missing `refresh_token` | Include the parameter in the request body. | | `400` | Bad decoded token | The value isn't a valid refresh token. Check you're not sending an access token by mistake. | | `400` | `invalid_grant` | The API (`audience`) the token was issued for no longer exists. Stop retrying and re-authenticate the user. | | `429` | Rate limit exceeded | You're refreshing too often. Refresh only when the access token is about to expire — or let `@faable/auth-js` schedule it for you. | > [!IMPORTANT] > Refresh only when needed — not on every request. The token endpoint is rate-limited per client, and a refresh loop (e.g. refreshing a 24-hour token every minute) will get throttled with `429` responses. --- ## ❓ FAQ ### Where do I get a refresh token in the first place? From a user login: the [Authorization Code flow](authorization-code.md) and the [Device Code flow](device-code.md) both return one alongside the access token. ### Why is a new refresh token returned every time? Rotation limits the damage of a leaked token: each refresh token is superseded by the next one. Always persist the latest value. ### Can I extend the scopes when refreshing? No — RFC 6749 §6 only allows narrowing. Scopes not granted originally are silently dropped. To gain new scopes, send the user through login again. ### Does the Client Credentials flow use refresh tokens? No. Machine-to-machine clients simply [request a new token](client-credentials.md) with their credentials when the current one expires. ### Do I have to implement any of this in a browser app? No — use `@faable/auth-js` and it's fully automatic, including multi-tab session sync. --- ## 🔗 Related - **[@faable/auth-js](https://www.npmjs.com/package/@faable/auth-js)** — official SDK with automatic token refresh built in. - **[Authorization Code Flow](authorization-code.md)** — where refresh tokens are first obtained. - **[Device Code Flow](device-code.md)** — login for CLIs and browser-less devices, also returns a refresh token. - **[Clients](../clients.md)** — application configuration. - **[RFC 6749 §6 — Refreshing an Access Token](https://datatracker.ietf.org/doc/html/rfc6749#section-6)** — the official standard. --- ## Token Exchange — Keyless CI/CD & Workload Identity (RFC 8693) Source: https://faable.com/docs/auth/oauth-flows/token-exchange # Token Exchange 🔁 The **OAuth 2.0 Token Exchange grant** (RFC 8693) lets a workload that already has a JWT from a trusted issuer — most commonly a **GitHub Actions job with its OIDC token** — exchange it for a Faable Auth access token. No API keys stored in CI, no secrets to rotate or leak: trust is established cryptographically between issuers. Use it for: - ✅ **Keyless CI/CD** — a GitHub Actions workflow authenticates as _itself_ (`repo:org/repo:ref:refs/heads/main`) to call your API - ✅ Workload identity federation — services holding a JWT from their own OIDC issuer - ✅ Replacing long-lived machine secrets with short-lived, verifiable identity This is **workload identity, not user login**: no Faable user is looked up or created. The issued token identifies the external workload itself. For user login use the [Authorization Code flow](authorization-code.md); for services that can hold a secret, [Client Credentials](client-credentials.md) is simpler. --- ## 📸 How It Works ```mermaid sequenceDiagram participant CI as GitHub Actions Job participant GH as GitHub OIDC Issuer participant Faable as Faable Auth participant API as Your API CI->>GH: Request OIDC id_token (audience=your client_id) GH-->>CI: Signed JWT (iss=token.actions.githubusercontent.com) CI->>Faable: POST /oauth/token (grant_type=token-exchange, subject_token) Note over Faable: Looks up the trusted Connection by iss,
verifies signature via its JWKS Faable-->>CI: Faable Access Token (30 min) CI->>API: Deploy / call API (Bearer token) ``` Faable reads the incoming token's `iss` claim, finds the **[Connection](../connections.md)** you configured for that issuer, verifies the token's signature against the connection's JWKS, and mints a Faable-signed access token carrying the external identity. --- ## ✅ Prerequisites From the [Faable Dashboard](https://dashboard.faable.com): 1. **A Client** — its `client_id` identifies the exchanging application. 2. **An OIDC Connection acting as the trust anchor**, configured with: | Connection field | Purpose | | :--------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `issuer` | The external issuer to trust — must equal the subject token's `iss` (e.g. `https://token.actions.githubusercontent.com`). | | `jwks_url` | Where to fetch the issuer's public keys. If omitted, Faable tries `/.well-known/jwks` — set it explicitly unless your issuer serves keys there (GitHub does). | | `client_id` | The expected `aud` of the subject token. The workload must request its OIDC token with this audience. | | `claims_mapping` | Optional — which external claims to copy into the issued Faable token (see below). | --- ## 🛠️ The Exchange Request - **Endpoint:** - **Method:** `POST` - **Content-Type:** `application/x-www-form-urlencoded` or `application/json` | Parameter | Required | Description | | :------------------- | :------- | :----------------------------------------------------------------------- | | `grant_type` | Yes | Must be `urn:ietf:params:oauth:grant-type:token-exchange`. | | `subject_token` | Yes | The external JWT to exchange (e.g. the GitHub Actions OIDC token). | | `subject_token_type` | Yes | Must be `urn:ietf:params:oauth:token-type:jwt` — the only type accepted. | | `client_id` | Yes | Your application's Client ID. | Extra body fields are allowed and can be lifted into the issued token via `claims_mapping` (as `request.` — see below). ### Response ```json { "token_type": "Bearer", "expires_in": 1800, "access_token": "eyJhbGciOiJSUzI1NiIs..." } ``` Exchanged tokens are deliberately short-lived — **30 minutes** — and the flow returns **no refresh token and no ID token**: when it expires, the workload simply exchanges a fresh subject token. Decoded, the access token looks like: ```json { "sub": "repo:acme/webapp:ref:refs/heads/main", "repository": "acme/webapp", "actor": "boyander", "client_id": "YOUR_CLIENT_ID", "iss": "https://your-domain.auth.faable.link", "exp": 1751643000 } ``` The `sub` is the external token's `sub` **verbatim** — for GitHub Actions, the repository/ref identity. `repository` and `actor` come from `claims_mapping`. --- ## 🧩 Mapping External Claims `claims_mapping` on the Connection controls which claims land in the issued token. Each entry maps an output claim to either a path or a literal: ```json { "repository": "token.repository", "actor": "token.actor", "environment": "request.environment", "via": "\"token-exchange\"" } ``` - `token.` — copied from the **verified** subject token. - `request.` — copied from the exchange request's body. - `"quoted"` values — static literals. Reserved claims (`iss`, `aud`, `sub`, `exp`, `iat`, `client_id`, `client`, `account`) are stripped from mappings — they're always controlled by Faable. Your backend then authorizes on these claims (e.g. only `repository: "acme/webapp"` may deploy). --- ## 🚀 Complete Example: GitHub Actions A workflow that authenticates to your API with **zero stored secrets**: ```yaml jobs: deploy: runs-on: ubuntu-latest permissions: id-token: write # allow the job to request its OIDC token steps: - name: Exchange GitHub OIDC token for a Faable token run: | # 1. Get the GitHub OIDC token (audience = your Connection's client_id) GITHUB_JWT=$(curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=YOUR_CLIENT_ID" | jq -r .value) # 2. Exchange it at your Faable tenant FAABLE_TOKEN=$(curl -s -X POST 'https://your-domain.auth.faable.link/oauth/token' \ -H 'content-type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ --data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:jwt' \ --data-urlencode "subject_token=$GITHUB_JWT" \ --data-urlencode 'client_id=YOUR_CLIENT_ID' | jq -r .access_token) # 3. Call your API as the workflow's identity curl -H "authorization: Bearer $FAABLE_TOKEN" https://api.myapp.com/deploy ``` --- ## ⚠️ Common Errors Errors use the standard OAuth 2.0 format: `{ "error": "..." }`. | Status | Error | Fix | | :----- | :-------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------- | | `400` | `Missing subject_token` / invalid JWT format / missing `iss` or `sub` claim | Send a complete, well-formed JWT as `subject_token`. | | `400` | `subject_token_type must be 'urn:ietf:params:oauth:token-type:jwt'` | Only JWT subject tokens are supported. | | `403` | `The OIDC issuer '' is not trusted by this account.` | Create a Connection whose `issuer` exactly matches the subject token's `iss`. | | `401` | `Invalid subject_token signature or audience` | Check the Connection's `jwks_url`, and request the subject token with `audience` = the Connection's `client_id`. | | `404` | `Client not found` | The `client_id` doesn't exist in this tenant. | --- ## ❓ FAQ ### Does Token Exchange create a Faable user? No. Unlike social login, no user is matched or created — the issued token's `sub` is the external workload identity verbatim. It's machine identity federation, not user login. ### How long does the exchanged token live? 30 minutes, by design. There's no refresh token: exchange a fresh subject token when it expires (a CI job can mint a new OIDC token at any time). ### Which external issuers can I trust? Any OIDC-compliant issuer that signs JWTs and publishes a JWKS — GitHub Actions, GitLab CI, Kubernetes service accounts, or another cloud's workload identity. One Connection per issuer. ### How does my backend authorize these tokens? Verify them like any Faable token ([Validate Access Tokens](../validate-access-tokens.md)) and authorize on the mapped claims — for example, allow deploys only when `repository` equals your repo and `sub` points at your main branch. ### When should I use Client Credentials instead? When your workload can hold a static secret securely (a server with a secret manager). Token Exchange shines where storing secrets is the problem — ephemeral CI runners, third-party compute. --- ## 🔗 Related - **[Connections](../connections.md)** — the trust anchor configuration. - **[Validate Access Tokens](../validate-access-tokens.md)** — verify the exchanged tokens in your API. - **[Client Credentials Flow](client-credentials.md)** — the secret-based alternative for backend services. - **[GitHub — About security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)** — how GitHub Actions OIDC works. - **[RFC 8693 — OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)** — the official standard. --- ## OpenID Connect Source: https://faable.com/docs/auth/oidc # OpenID Connect **OpenID Connect (OIDC)** is the identity layer on top of OAuth 2.0. Where OAuth alone answers _"can this client access this API?"_, OIDC also answers _"who is the user?"_ — by returning a signed **ID token** alongside the access token and standardizing how to fetch profile claims. Faable Auth implements [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html). Every tenant domain is a fully self-describing OIDC issuer, so any standard OIDC client library works out of the box. ## Discovery Each tenant publishes its configuration at the standard well-known URL: ```http GET https://your-tenant.faable.app/.well-known/openid-configuration ``` The document lists the issuer, the `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, `jwks_uri`, `end_session_endpoint`, and the supported scopes, response types, and signing algorithms. Point your OIDC library at your tenant domain and it configures itself from here. ## ID tokens and signing keys ID tokens (and access tokens) are JWTs signed with **RS256**. The public keys are published at: ```http GET https://your-tenant.faable.app/.well-known/jwks.json ``` Keys rotate automatically; each token's `kid` header identifies the key that signed it, so libraries that cache the JWKS pick up rotations transparently. See [Validate Access Tokens](/auth/validate-access-tokens) for how to verify tokens in your own API. ## What's supported | Capability | Notes | | :--------------------------------------- | :------------------------------------------------------------------------------- | | Authorization Code Flow + PKCE (S256) | The recommended login flow — see [OAuth 2.0 Flows](/auth/oauth-flows) | | Discovery | `/.well-known/openid-configuration` per tenant | | JWKS with key rotation | `/.well-known/jwks.json`, RS256 | | `nonce` | Replay protection for ID tokens | | `prompt` | `none`, `login`, `consent`, `select_account` | | `max_age` + `auth_time` | Force or measure re-authentication | | [UserInfo endpoint](/auth/oidc/userinfo) | Claims gated by scopes (§5.4) | | [RP-Initiated Logout](/auth/oidc/logout) | `id_token_hint`, `post_logout_redirect_uri` | | Dynamic Client Registration | [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591), `POST /oidc/register` | ## Standard claims Which claims end up in the ID token and the UserInfo response is controlled by the **scopes** you request at `/authorize` time — `openid` is required, and `profile`, `email`, `phone`, `address` unlock their claim groups. The full scope-to-claim mapping is documented on the [UserInfo page](/auth/oidc/userinfo#scope-to-claim-mapping). ## Next steps - [OAuth 2.0 Flows](/auth/oauth-flows) — pick the right grant and obtain your first tokens. - [UserInfo Endpoint](/auth/oidc/userinfo) — fetch end-user profile claims with the access token. - [RP-Initiated Logout](/auth/oidc/logout) — sign users out and propagate it to relying parties. - [Validate Access Tokens](/auth/validate-access-tokens) — verify RS256 signatures against your tenant's JWKS in your API. --- ## Validate Access Tokens in Your API — Node.js & Express Source: https://faable.com/docs/auth/validate-access-tokens # Validate Access Tokens in Your API 🛡️ Your frontend or a machine-to-machine client sends requests to your backend with an access token: ``` Authorization: Bearer eyJhbGciOiJSUzI1NiIs... ``` This guide shows how to **verify that token in your API** — Node.js/Express in the examples, but the checks are the same in any stack. Validation is fully local: your API verifies the token's signature against your tenant's public keys, so there's **no call to Faable on each request**. A valid token must pass four checks: 1. **Signature** — RS256, against your tenant's JWKS (public keys). 2. **Issuer (`iss`)** — your tenant URL, exactly. 3. **Audience (`aud`)** — the `identifier` of your registered [API](apis.md). 4. **Expiry (`exp`)** — handled automatically by any JWT library. Then you authorize the request using the token's `scope` / `permissions` claims — and, if you enable them, `teams` / `roles`. > **Protecting Next.js routes instead of an API?** This guide is for verifying a > `Bearer` token your backend receives (typically machine-to-machine or a SPA > calling your API). To gate Next.js pages, Route Handlers, or middleware by the > user's **session cookie**, read it with `getSessionFromCookies` — see > [Next.js (Server-Side)](quickstart/nextjs-server-side.md). --- ## 🔍 What's Inside a Faable Access Token Access tokens are RS256-signed JWTs. Decoded, they look like this: ```json { "sub": "user_66f1a2b3c4d5e6f7a8b9c0d1", "scope": "openid profile email read:orders", "permissions": "read:orders", "client_id": "AbC123xYz...", "account": "acc_6612ab34cd56ef7890ab12cd", "aud": "https://api.myapp.com", "iss": "https://your-domain.auth.faable.link", "iat": 1751641200, "exp": 1751727600 } ``` | Claim | Description | | :---------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sub` | Who the token represents: a user id (`user_…`) for login flows, or the `client_id` for [machine-to-machine](oauth-flows/client-credentials.md) tokens. | | `scope` | Space-separated string of granted scopes. | | `permissions` | Space-separated **string** (not an array) of granted API permissions. Only present when your API uses the `access_token_authz` token dialect — see [APIs](apis.md). | | `client_id` | The client that obtained the token. Useful for logging and for per-client rules. | | `account` | Your tenant's id. Every token carries it; you can ignore it for validation because `iss` already pins the tenant. | | `aud` | The `identifier` of the API the token was issued for. When no `audience` was requested it falls back to `/userinfo` — see [Pitfalls](#pitfalls-that-cause-invalid-token). | | `iss` | Your tenant URL — **no trailing slash**. Custom-domain tenants issue with the custom domain (`https://login.yourapp.com`). | | `exp` | Expiry (seconds since epoch). Lifetime comes from your API's `token_lifetime` (default 24 h). | | `teams` / `roles` | Optional string arrays, included when your API enables `include_teams_in_access_token` / `include_roles_in_access_token`. Use them for team- or role-based authorization without a database lookup per request. | Your tenant publishes its public keys at and its OIDC metadata at . Keys are **per tenant** and rotate — always resolve them by the token header's `kid` via JWKS instead of pinning a fixed key (any JWKS library does this for you). --- ## 💻 Express Middleware with `jose` The complete pattern using [`jose`](https://www.npmjs.com/package/jose), which caches the JWKS and re-fetches it on key rotation automatically: ```ts import express from 'express' import { type JWTPayload, createRemoteJWKSet, jwtVerify } from 'jose' const ISSUER = 'https://your-domain.auth.faable.link' // your tenant — NO trailing slash const AUDIENCE = 'https://api.myapp.com' // your API's identifier in the dashboard const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`)) async function checkJwt(req, res, next) { const token = req.headers.authorization?.replace(/^Bearer /i, '') if (!token) { res.set('WWW-Authenticate', 'Bearer') return res.status(401).json({ error: 'missing_token' }) } try { const { payload } = await jwtVerify(token, JWKS, { issuer: ISSUER, audience: AUDIENCE, // also rejects OIDC-only tokens issued without an audience algorithms: ['RS256'] }) req.auth = payload // exp/iat already validated by jwtVerify next() } catch { res.set('WWW-Authenticate', 'Bearer error="invalid_token"') res.status(401).json({ error: 'invalid_token' }) } } // Authorize per-route: `scope` and `permissions` are space-separated strings const requireScope = (required: string) => (req, res, next) => { const granted = new Set( `${req.auth.scope ?? ''} ${req.auth.permissions ?? ''}`.split(/\s+/) ) if (!granted.has(required)) { res.set( 'WWW-Authenticate', `Bearer error="insufficient_scope", scope="${required}"` ) return res.status(403).json({ error: 'insufficient_scope' }) } next() } const app = express() app.get('/orders', checkJwt, requireScope('read:orders'), (req, res) => { res.json({ orders: [], for: req.auth.sub }) }) app.listen(3000) ``` Two status codes, on purpose: **`401`** means "I can't trust who you are" (missing, expired, wrong signature, wrong audience) and **`403`** means "I know who you are and you're not allowed" (scope missing). The `WWW-Authenticate` header is what [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750#section-3) prescribes; SDKs and API gateways use it to decide whether to refresh the token or give up. ### The same four checks, mapped to library options | Check | `jose` (Node) | PyJWT (Python) | | :-------- | :--------------------------- | :------------------------------------------ | | Signature | `createRemoteJWKSet` + `kid` | `PyJWKClient(...).get_signing_key_from_jwt` | | Issuer | `issuer: ISSUER` | `issuer=ISSUER` | | Audience | `audience: AUDIENCE` | `audience=AUDIENCE` | | Expiry | automatic | automatic | | Algorithm | `algorithms: ['RS256']` | `algorithms=["RS256"]` | **Python, in twelve lines** (the full FastAPI version with dependencies and scope enforcement is in the [FastAPI Quickstart](quickstart/fastapi.md)): ```python import jwt # PyJWT ISSUER = "https://your-domain.auth.faable.link" AUDIENCE = "https://api.myapp.com" jwks = jwt.PyJWKClient(f"{ISSUER}/.well-known/jwks.json") # caches keys, handles rotation def verify(token: str) -> dict: key = jwks.get_signing_key_from_jwt(token).key return jwt.decode(token, key, algorithms=["RS256"], issuer=ISSUER, audience=AUDIENCE) def has_scope(claims: dict, required: str) -> bool: granted = set(f"{claims.get('scope', '')} {claims.get('permissions', '')}".split()) return required in granted ``` Go, Java and .NET all have JWKS-aware JWT libraries; point them at your tenant's discovery URL and set issuer, audience and RS256 the same way. --- ## ⚠️ Pitfalls That Cause "Invalid Token" These are the mistakes we see most often, especially when porting middleware from other providers: - **Trailing slash on the issuer.** Faable's `iss` is `https://your-domain.auth.faable.link` — no trailing slash. Auth0-style configs with a trailing `/` reject every token. - **Treating `permissions` as an array.** Faable emits it as a **space-separated string**; Auth0 emits an array. `payload.permissions.includes("read:orders")` on a string does a substring match — `read:orders` would "match" `read:orders_archive`. Split on whitespace first (as `requireScope` above does). - **Skipping the `audience` check.** A token requested without an `audience` is still valid OIDC-wise, but its `aud` is `https://your-domain.auth.faable.link/userinfo` — it was only ever meant for the [UserInfo endpoint](oidc/userinfo.md), not your API. Always validate `aud` against your API identifier; that single check rejects these. If you see this `aud` in your logs, the client forgot to send `audience` on `/authorize` or `/oauth/token`. - **Validating access tokens against the `client_id`.** The **ID token's** `aud` is the client id; the **access token's** `aud` is the API identifier. Your backend validates access tokens — use the API identifier. - **Verifying the ID token instead of the access token.** The ID token proves _who logged in_ to the app that requested it; it is not meant for your API and its audience won't match. Clients should send the access token. - **Pinning a single public key.** Tenant signing keys rotate. Resolve keys via JWKS using the token header's `kid` (remote JWKS helpers handle caching and rotation). - **Custom domain mismatch.** If your tenant runs on a [custom domain](custom-domain.md), tokens are issued with that domain as `iss`. Configure the issuer your clients actually log in through, and fetch the JWKS from the same host. --- ## 🧪 Testing Your Middleware Get a real token for your API with the [Client Credentials flow](oauth-flows/client-credentials.md) and call your endpoint: ```bash TOKEN=$(curl -s -X POST 'https://your-domain.auth.faable.link/oauth/token' \ -H 'content-type: application/json' \ -d '{"grant_type":"client_credentials","client_id":"...","client_secret":"...","audience":"https://api.myapp.com"}' \ | jq -r .access_token) curl -i http://localhost:3000/orders -H "authorization: Bearer $TOKEN" ``` Then break it on purpose — the checks are only proven once each one has rejected something: ```bash curl -i http://localhost:3000/orders # 401 missing_token curl -i http://localhost:3000/orders -H "authorization: Bearer ${TOKEN}x" # 401 invalid_token (bad signature) ``` Request the token without `audience` and you should get a `401` too — that is the `aud` check doing its job. Decode any token you're unsure about with [jwt.io](https://jwt.io) (client-side only; nothing is sent). --- ## ❓ FAQ ### Does my API call Faable on every request? No. Verification is local — your API checks the RS256 signature against the JWKS, which the library caches. Faable is only contacted when the JWKS cache is cold or a new `kid` appears (key rotation). ### What's the difference between `scope` and `permissions`? `scope` is what the client requested and was granted (standard OAuth). `permissions` appears when your API uses the `access_token_authz` dialect: with `enforce_policies` enabled it's the requested scopes **filtered against your API's permission catalog**, so it's the claim to trust for authorization. Without `enforce_policies` the requested scopes are echoed verbatim. See [APIs](apis.md). ### Why is there no `permissions` claim in my token? Your API is using the default `access_token` dialect, which never emits it. Switch the API's token dialect to `access_token_authz` in the dashboard, or authorize on `scope` instead. ### Why does my token say `aud: "https://…/userinfo"`? The client didn't ask for your API. Add `audience=https://api.myapp.com` to the `/authorize` request (or to the `/oauth/token` request for client credentials). Your API is right to reject such a token. ### How do I know if the caller is a user or a machine? Look at `sub`: user tokens carry a `user_…` id; [machine-to-machine](oauth-flows/client-credentials.md) tokens carry the client's `client_id`. ### How do I authorize by team or role? Enable `include_teams_in_access_token` / `include_roles_in_access_token` on your API. The token then carries `teams` and `roles` as string arrays, so a route can check `req.auth.roles?.includes('admin')` with no database lookup. Membership changes show up on the next token, not the current one — keep `token_lifetime` short if that matters. ### How long is an access token valid? 24 hours by default, configurable per API through `token_lifetime`. A token stays valid until `exp` even if the user is suspended or logs out — a shorter lifetime bounds that window, and the [Refresh Token flow](oauth-flows/refresh-token.md) keeps sessions alive without long-lived access tokens. ### Can I use a library other than jose? Yes — any JWT library with JWKS support works (`express-oauth2-jwt-bearer`, `jwks-rsa` + `jsonwebtoken`, or your stack's equivalent). Configure it with your tenant issuer (no trailing slash), your API identifier as audience, and RS256. ### How do I do this in Python? Same four checks with [PyJWT](https://pyjwt.readthedocs.io/) — `PyJWKClient` handles the JWKS cache and key rotation; the snippet above is the whole thing. See the [FastAPI Quickstart](quickstart/fastapi.md) for the complete dependency-based version, including scope enforcement. --- ## 🔗 Related - **[FastAPI Quickstart](quickstart/fastapi.md)** — the Python version of this guide, with PyJWT and FastAPI dependencies. - **[APIs](apis.md)** — register your API, define its `identifier` (audience), token dialect and permission catalog. - **[Client Credentials Flow](oauth-flows/client-credentials.md)** — how machine clients obtain the tokens your API validates. - **[Authorization Code Flow](oauth-flows/authorization-code.md)** — how user tokens are issued, with [`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js). - **[UserInfo Endpoint](oidc/userinfo.md)** — what a token without an audience is actually for. - **[RFC 6750 — Bearer Token Usage](https://datatracker.ietf.org/doc/html/rfc6750)**, **[RFC 7517 — JSON Web Key (JWK)](https://datatracker.ietf.org/doc/html/rfc7517)** and **[RFC 9068 — JWT Access Tokens](https://datatracker.ietf.org/doc/html/rfc9068)** — the standards involved. --- ## Login Flows Source: https://faable.com/docs/auth/login-flows # Login Flows A login is a sequence of screens with decisions between them: choose a method, prove a credential, maybe a second factor, maybe an invitation to create a passkey, then back to the app. Faable Auth keeps that sequence as a **graph** — and lets you read it, change it, try it and publish it, for the whole account or for one client. ## What a login flow is Nodes are typed steps; edges leave a node through a **named handle** (`then` / `else` on a condition, `satisfied` / `challenge` / `enroll` on the second-factor gate). A login starts at `start`, walks edges, and ends at `end` (tokens are issued) or `deny`. The one rule that governs everything: **the flow decides the order of screens; your settings decide the rules.** A node that reads a policy — which methods to offer, whether a second factor is owed, whether to offer a passkey — reads it from [Login Experience](login-experience.md) and [Two-Step Verification](mfa.md) at run time. It never carries a copy. A flow therefore cannot remove what a policy requires: publishing a graph with no second-factor gate while the policy is `required` is refused. ## The default flow Every account runs a flow already: the one compiled from its settings. Open **Auth → Login Flow** to see it. With two-step verification on, a gate appears after the Actions with its three ways out; with the passkey offer on, an invitation hangs off the "nothing owed" path — and only there. Whoever just passed or enrolled a second factor is never offered a passkey on top. That rule is topology, not code: you can see it. Nothing is stored for the default flow. Change a setting and the graph you see changes with it. ## One tenant, different access per client Each client can run its own flow. The precedence is by whole graph: the client's flow if it has one, else the account's, else the compiled one — reflecting **that client's** overrides, so an admin app with `mfa_policy: required` compiles to a gated graph while the consumer app of the same tenant does not. On a client, **Login flow** says which graph it runs. **Customize for this client** gives it its own, starting from what it runs today. ## Nodes | Node | What it does | Reads | | --------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------- | | Start | Where every login begins. | — | | Email first | Asks for the email before showing any method. Nothing is looked up. | `identifier_first` | | Choose a method | The hosted login screen: which methods, in what order. | `login_methods` | | Credential | The user proved who they are (password, code, social, passkey). | — | | Actions | Runs your post-login [Actions](extensibility/actions.md). A deny stops here. | Actions | | Second factor? | Decides whether a factor is owed: `satisfied`, `challenge` or `enroll`. | `mfa_policy` | | Verify factor | Asks for the code, key or passkey the user has. Which ones it offers, in what order and which is the default are set on the node. | — | | Enrol factor | Enrols a first factor during the login. | — | | Offer a passkey | Invites the user to create a passkey. Never blocks. | `passkey_promotion` | | Condition | Branches on a fact: `then` / `else`. | — | | Deny | Refuses the login with an error you name. | — | | Sign in | Issues the tokens and returns the user to the app. | — | ### The verification screen **Verify factor** opens on a choice of cards — _Use a security key or biometrics_ (named after the device: Touch ID, Face ID, Windows Hello) and _Use your authenticator app_ — with one of them tagged as the default. Select the node to choose which ways to verify it lists, their order, the default, and whether recovery codes are offered as a way in. A user with only an authenticator app skips the choice and lands on the code field. The screen only ever lists factors the user actually has, so hiding one here never locks anybody out. Which factors are _accepted_ is the two-step verification policy's **allowed factors**; the node decides what the screen suggests. ```json { "id": "mfa_challenge", "type": "mfa_challenge", "config": { "factors": ["webauthn", "totp"], "preferred": "webauthn", "recovery": true } } ``` ## Conditions A condition is one of a closed set of facts — there is no expression language, on purpose: anything you could write in one is a security surface in the login path and cannot be checked before it runs. Arbitrary logic belongs in an [Action](extensibility/actions.md). | Condition | Example use | | --------------------------- | ------------------------------------------------------ | | User has a second factor | Skip the passkey offer for users who already have one. | | User just signed up | Show something once, on the first login only. | | Session assurance level ≥ n | Route differently after a second factor. | | Client is one of … | A branch only the admin app takes. | | Connection type is … | Different treatment for social sign-ins. | | Device is remembered | The browser passed a challenge recently. | Each can be negated. ## Editing, previewing and publishing **Customize** turns the compiled graph into a flow of your own — never a blank canvas. From then on the page shows two views: **Running** (what logins walk) and **Draft** (what you are editing). - **Validate** checks the draft against the rules a publish enforces and paints each problem on its node: exactly one start; every handle connected; nothing unreachable; no cycle without a condition; a second-factor gate on every path when the policy wants one; the passkey offer only with the offer on. - **Try this flow** opens a real login in a new tab that runs the **draft**, for your browser only, for ten minutes. Nothing is published. - **Publish** freezes the draft under its revision. New logins run it; logins already in flight finish on the revision they started. The previous revision goes to history. - **Roll back** makes a previous revision run again, without touching the draft. ## Recipes ### Require a second factor only for the admin app No flow needed: set **Two-step verification → Required** on the admin client. Its compiled graph grows the gate; the consumer app's does not. Open **Login Flow** with the admin client selected to see the difference. ### No new signups on the admin client Customize the admin client's flow. After **Credential**, add a **Condition** — _User just signed up_ — with `then` to a **Deny** (`signups_closed`, 403) and `else` on to Actions. ```json { "id": "no_signups", "type": "condition", "config": { "predicate": { "op": "user.is_new" } } } ``` ### Offer a passkey right after the first login only With the passkey offer on, add a **Condition** — _User just signed up_ — before **Offer a passkey**: `then` to the offer, `else` to Sign in. ## API ```bash # Start a custom flow from what runs today (account, or one client) curl -X POST https:///loginflow/materialize \ -H "Authorization: Bearer $MANAGEMENT_TOKEN" -H "Content-Type: application/json" \ -d '{ "client_id": "client_xxx" }' # What runs right now, and the bound flow with its draft curl https:///loginflow/resolved?client_id=client_xxx -H "Authorization: Bearer $MANAGEMENT_TOKEN" # Edit the draft, check it, publish it, roll it back curl -X POST https:///loginflow/loginflow_xxx -d '{ "graph": { ... }, "layout": { ... } }' ... curl -X POST https:///loginflow/loginflow_xxx/validate ... curl -X POST https:///loginflow/loginflow_xxx/publish ... curl -X POST https:///loginflow/loginflow_xxx/rollback -d '{ "revision": 3 }' ... # A preview token: run the draft on one login, in your browser curl -X POST https:///loginflow/loginflow_xxx/preview-token ... # → open /authorize?client_id=…&redirect_uri=…&response_type=code&scope=openid&flow_preview= ``` Every endpoint is in the OpenAPI reference under `loginflow`. ## FAQ ### Can a flow disable two-step verification? No. The gate node reads the policy; it does not carry one. With the policy on, a graph that lets a login reach _Sign in_ without passing the gate does not validate and cannot be published. ### Does publishing affect logins already in progress? No. A login pins the revision it started on when it starts, and finishes on it. New logins take the new revision. ### Do direct grants run the flow? No. Token-endpoint grants — the passwordless code grant, client credentials — have no browser and no screens to order. They apply the same policies (Actions, two-step verification) directly. ### What happens if my flow has an error? Publishing refuses it with the list of problems. If a published graph ever fails at run time — a node that cannot be reached, a loop — the login fails with an error and an audit row `auth.login_flow.error`; it never loops the user's browser. ## Related - [Login Experience](login-experience.md) — the settings the flow's nodes read. - [Two-Step Verification](mfa.md) — the policy behind the gate. - [Actions](extensibility/actions.md) — arbitrary logic, in code. --- ## Shopify Plus customer accounts Source: https://faable.com/docs/auth/integrations/shopify # Shopify Plus customer accounts Shopify Plus lets a store replace the built-in customer login with **your own OpenID Connect identity provider**. Point it at Faable Auth and your shoppers sign in to the storefront with the same account they use in the rest of your product — one identity, one password reset, one audit log, and the same social logins, passkeys and two-step verification you already offer. Faable Auth satisfies every requirement Shopify publishes for a third-party provider: authorization code flow, PKCE (S256), discovery, JWKS, RS256 signing, refresh tokens and RP-Initiated Logout. The contract is pinned by end-to-end tests in the server, so a release that would break the storefront login does not ship. ## Why one account for your product and your store Out of the box, a Shopify store has its own customer list, its own login and its own password resets. If you also run an app, a member area or a customer portal, your customers end up with **two accounts that do not know about each other**: two emails to remember, two "forgot password" flows, and no way for your product to know what someone bought or for the store to know who is a member. Connecting Shopify to Faable Auth collapses that into one identity: ```mermaid flowchart LR U((Shopper)) --> A[Your web / mobile app] U --> S[Shopify storefront] U --> P[Customer portal, community, ...] A --> F[Faable Auth
one user, one login] S --> F P --> F F -. tags, addresses, name .-> S F -. roles, teams, claims .-> A ``` | Without the integration | With Faable Auth as the store's identity provider | | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | Separate signup and password for the store | The store reuses the account the shopper already has; a new shopper on the store gets an app account too | | Store login is email + password only | Google, GitHub, Microsoft, magic links, passkeys and two-step verification, the same as in your app | | Membership status lives in your database, invisible to Shopify | An Action turns it into a Shopify customer tag on every sign-in; Shopify pricing and segments react to it | | Blocking a fraudulent customer means two admin panels | Suspending the user in Faable Auth ends the store session at the next token refresh | | Support reconstructs "who signed in where" from two systems | One audit log with every login, denial and token issued for the store | | Customer identity belongs to the store | Identity belongs to you; adding a second store, a mobile app or a new channel does not create new accounts | ### Real-world scenarios **A fitness app that also sells equipment.** Members subscribe inside the app and buy mats, bands and apparel in a Shopify store. With one account, a member who taps "Shop" in the app is already signed in on the storefront, and the loyalty tier stored in the app becomes a `loyalty-gold` tag in Shopify that unlocks member pricing through Shopify's own discount rules. A visitor who first buys a mat on the store and later downloads the app signs in with the same email and password, or the same passkey, without a second signup. **A B2B distributor with a customer portal and a wholesale store.** Buyers use the portal for invoices and order history, and a Shopify Plus B2B storefront for ordering. Faable Auth is the one login for both. [Team invitations](../team-invitations.md) bring a new purchasing manager into a customer's team; an Action reads the team's terms and emits a `wholesale` tag and the company's shipping addresses, so the buyer lands in Shopify with the right catalog and addresses already in place. When a customer stops paying, suspending the account in Faable Auth blocks the portal immediately and the store at the next refresh. **A media brand with a community and a merch store.** The community runs on [passwordless](../passwordless.mdx) login: a magic link, no password ever. Because every passwordless user has a verified email by construction, the same accounts satisfy Shopify's `email_verified` requirement with nothing else to configure. Newsletter readers buy merch with the link they already use, and the `signup-web` tag from the Action tells the store which customers came from the community. **A hardware company with a device app and a spare-parts store.** Owners set up their device in a mobile app protected with passkeys. Buying a replacement filter in the Shopify store uses the same passkey: Face ID on the phone, no password, and the shipping address from the app profile arrives in Shopify through the standard `address` claim, so checkout is pre-filled. **A brand with several stores.** An EU store and a US store are two Shopify stores, each connected as its own client in the same Faable Auth tenant. A customer who moves between them is one person with one login and one order history on your side. Shopify still keeps a customer record per store, but neither store owns the identity; you do. ### What the shopper notices Almost nothing, which is the point. The "Sign in" button on the store sends them to your login page on your domain, with your branding and the sign-in methods your app already offers. After signing in they are back on the store, signed in, for up to 90 days. If they signed in to your app through Faable Auth recently in the same browser, the store sign-in is a single redirect with no form at all. ## How it works Shopify is a regular OpenID Connect Relying Party. It reads your discovery document once, sends shoppers to your `/authorize` endpoint, exchanges the code for tokens, and reads `sub` and `email` from the ID token to find or create the customer. ```mermaid sequenceDiagram participant Shopper participant Shopify as Shopify (account.acme.com) participant Faable as Faable Auth (login.acme.com) Shopper->>Shopify: Clicks "Sign in" Shopify->>Faable: GET /authorize
response_type=code, scope=openid email,
code_challenge (S256), nonce, state Shopper->>Faable: Logs in (password, passkey, Google, magic link, ...) Note over Faable: Actions run, two-step verification if required Faable-->>Shopify: 302 to /authentication/login/external/callback
?code=...&state=... Shopify->>Faable: POST /oauth/token (HTTP Basic client_id:client_secret)
grant_type=authorization_code, code, code_verifier Faable-->>Shopify: id_token + access_token + refresh_token Note over Shopify: Verifies signature via JWKS, iss, aud, nonce.
Requires email_verified: true.
Finds customer by email, links sub. Shopify-->>Shopper: Signed in on the storefront Shopify->>Faable: POST /oauth/token grant_type=refresh_token (silently, up to 90 days) Shopper->>Shopify: Clicks "Log out" Shopify->>Faable: GET /logout?id_token_hint=...&post_logout_redirect_uri=... Faable-->>Shopify: 302 to /logout_callback ``` Three things in that diagram cause nearly every support ticket, so they get their own sections below: the **issuer must match byte for byte**, the ID token must carry **`email_verified: true`**, and the **post-logout URL must be registered** on the Faable side. ## Before you start - **A Shopify Plus plan.** Connecting your own identity provider is a Plus-only feature. On any other plan the option does not exist in the Shopify admin. - **A Faable Auth tenant on HTTPS** — your [custom domain](../custom-domain.md) or the `*.auth.faable.link` one. Decide the hostname now; changing it later means re-doing the Shopify side. - **At least one connection** your shoppers will use to sign in — see [Connections](../connections.md). Read [Email verification is required](#email-verification-is-required) before choosing. ### The example used on this page | Thing | Value in the examples | | ---------------------------------- | ----------------------------------------- | | Store | Acme Outdoor, `acme.com` | | Shopify customer accounts domain | `account.acme.com` | | Faable Auth tenant (custom domain) | `login.acme.com` | | Faable Auth client | "Shopify storefront", `client_id=cl_7f3…` | Replace `login.acme.com` with your tenant hostname and `account.acme.com` with the customer accounts domain Shopify shows you. ## Step 1 — Open the connection form in Shopify 1. In the Shopify admin go to **Settings → Customer accounts**. 2. In the **Identity provider** section click **Manage**, then **Connect to provider**. 3. Enter a name shoppers will recognise in **Identity provider name** (for example "Acme account"). 4. Keep this page open. The **Setup configurations** section shows the two URLs Shopify wants you to register on the provider side: | Shopify calls it | Looks like | Goes into Faable Auth as | | ---------------- | ----------------------------------------------------------------- | ------------------------ | | **Callback URL** | `https://account.acme.com/authentication/login/external/callback` | Callback URLs | | **Logout URL** | `https://account.acme.com/logout_callback` | Logout URLs | Copy them exactly as displayed. Shopify may list more than one logout URL; register all of them. ## Step 2 — Create the client in Faable Auth 1. In the [Faable Dashboard](https://dashboard.faable.com) open your Auth tenant → **Clients** → **Create**. Name it "Shopify storefront". Shopify is a confidential client (it keeps the secret on its servers), so a regular web application is the right kind. 2. Paste Shopify's **Callback URL** into **Callback URLs**. 3. Paste Shopify's **Logout URL(s)** into **Logout URLs**. 4. Leave **Allowed Web Origins** empty. Shopify never calls the token endpoint from a browser. 5. Note the **Client ID** and **Client Secret**. You paste them into Shopify in the next step. If you prefer the [Management API](../academy/05-server-and-management-api.md) (the SDK call maps one-to-one to `POST /client`): ```ts import { FaableAuthApi, authClientCredentials } from '@faable/auth-sdk' const api = FaableAuthApi.create({ domain: 'login.acme.com', authStrategy: authClientCredentials, auth: { client_id: process.env.FAABLEAUTH_CLIENT_ID!, client_secret: process.env.FAABLEAUTH_CLIENT_SECRET! } }) const client = await api.clientCreate({ name: 'Shopify storefront', callbacks: [ 'https://account.acme.com/authentication/login/external/callback' ], logout_urls: ['https://account.acme.com/logout_callback'], web_origins: [] }) // client.client_id, client.client_secret ``` ### Refresh token lifetime Shopify keeps a shopper signed in for **up to 90 days** by refreshing tokens in the background. Faable Auth always returns a refresh token on the authorization code exchange and **rotates** it on every refresh, so the session lives as long as the shopper comes back before the current refresh token expires. The default lifetime is **30 days** of inactivity. To let Shopify use the full 90 days, open the client → **Refresh token** and set the lifetime to `7776000` seconds. You do not need to request `offline_access`; adding it to Shopify's scopes is harmless. ## Step 3 — Paste the provider details into Shopify Back in the Shopify form, fill the **Application info** section: | Shopify field | Value | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Discovery URL** | `https://login.acme.com/.well-known/openid-configuration` | | **Client ID** | The client's **Client ID** | | **Client secret** | The client's **Client Secret** | | **Additional scopes** | Leave empty for login only. Add `profile phone address` to also sync names, phone and address — see [Sync customer data](#sync-customer-data-into-shopify). | Shopify always requests `openid email` on its own. Its default token endpoint authentication method, `client_secret_basic` (HTTP Basic), is what Faable Auth expects, so there is nothing to switch. If the form asks for endpoints individually instead of reading discovery: | Endpoint | URL | | ------------- | ---------------------------------------------- | | Authorization | `https://login.acme.com/authorize` | | Token | `https://login.acme.com/oauth/token` | | UserInfo | `https://login.acme.com/userinfo` | | JWKS | `https://login.acme.com/.well-known/jwks.json` | | Logout | `https://login.acme.com/logout` | > [!IMPORTANT] > **Use the exact same hostname everywhere, with no trailing slash.** Faable Auth derives the `iss` claim from the host that served the request. A discovery document fetched from `login.acme.com` advertises `"issuer": "https://login.acme.com"` and every endpoint under it, and the ID tokens carry that same issuer. If Shopify reads discovery from one hostname while shoppers are sent to another (`acme.auth.faable.link` vs `login.acme.com`, `www.`, `http://`), Shopify rejects the ID token because the issuer does not match. You can check what Shopify will see: ```bash curl -s https://login.acme.com/.well-known/openid-configuration | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri, end_session_endpoint, response_types_supported, grant_types_supported, code_challenge_methods_supported, token_endpoint_auth_methods_supported}' ``` ```json { "issuer": "https://login.acme.com", "authorization_endpoint": "https://login.acme.com/authorize", "token_endpoint": "https://login.acme.com/oauth/token", "jwks_uri": "https://login.acme.com/.well-known/jwks.json", "end_session_endpoint": "https://login.acme.com/logout", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token", "..."], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["client_secret_basic", "none"] } ``` ## Step 4 — Test and activate 1. Click **Save**. 2. Click **Test connection**. Shopify redirects you to your Faable Auth login page; sign in with a user whose email is verified. 3. Back in Shopify, click **Activate**. An **Active** badge appears next to the provider name. From this moment the default Shopify login is gone for this store. Shoppers who were signed in are asked to sign in again through Faable Auth. Shopify matches them by email: an ID token whose `email` matches an existing customer signs into that customer and links its `sub`; an unknown email creates a new customer. The `sub` is shown as the subject ID on the customer profile, and an admin can **Unlink** it if a profile was ever linked to the wrong identity. ## What the ID token looks like This is what Faable Auth returns to Shopify for the example shopper, with `openid email profile` requested and no Actions involved: ```json { "iss": "https://login.acme.com", "sub": "user_9c2d1e6a4f8b", "aud": "cl_7f3a9d2e", "iat": 1757152000, "exp": 1757238400, "nonce": "n-0S6_WzA2Mj", "auth_time": 1757151998, "amr": ["pwd", "otp"], "acr": "urn:faable:loa:2", "sid": "sid_4b1e…", "email": "jane@acme.com", "email_verified": true, "name": "Jane Doe", "given_name": "Jane", "family_name": "Doe", "picture": "https://…", "updated_at": 1757000000 } ``` | Claim | Shopify uses it for | | ---------------- | ----------------------------------------------------------------------------------------- | | `iss`, `aud` | Must equal the issuer from discovery and your Client ID. Otherwise the token is rejected. | | `nonce` | Replay protection. Echoed from the `/authorize` request. | | `sub` | Stable link to the Shopify customer. | | `email` | Finds or creates the customer. | | `email_verified` | **Must be `true`** or Shopify refuses the sign-in. | | `given_name` … | Optional profile sync, see below. | ## Email verification is required This is the part worth reading twice. **Shopify only provisions a customer account when the ID token carries `email_verified: true`.** A user whose email Faable Auth has not verified can authenticate perfectly and still be turned away by Shopify with a generic error. Faable Auth reports `email_verified` honestly — it never asserts a verification that did not happen — so which connection your users sign in with decides whether they can shop: | Connection | `email_verified` | | ------------------------------------------------------ | ------------------------------------------------------------------ | | **Passwordless** (magic link or code) | ✅ always — proving control of the inbox _is_ the login | | Email + password (database) | ❌ **not at signup** — until the user clicks the verification link | | Google, GitHub | ✅ when the provider attests it | | **Microsoft (Entra ID)** | ❌ never — see below | | After a verification link, invitation, or email change | ✅ | **Recommendation: use a [passwordless](../passwordless.mdx) connection for stores.** Every user is verified by construction and there is nothing else to configure. If you use **email + password**, turn on **Auto-send email verification** for the tenant (Dashboard → Auth → Settings → **Notifications**) so new users receive the verification link at signup. Until they click it, their first Shopify sign-in fails. **Microsoft never reports a verified email**, and that is deliberate. Entra sources the email from the directory's `mail` attribute, which a tenant administrator can point at a domain they do not own — so treating it as verified would let them claim someone else's customer account. Microsoft users must complete Faable Auth's own email verification before they can use the storefront. To check a specific user, read `email_verified` on `GET /user/:id`, or open the user in the dashboard. Do **not** set `email_verified: true` by hand to "fix" a login: you would be telling Shopify that a verification happened when it did not. ### Optional: fail early with a clear message Shopify's error page for a rejected token is generic. If you want unverified users to learn what to do while they are still on your login page, add an [Action](../extensibility/actions.md) that denies the login for the Shopify client only: ```js // Action: "Shopify requires a verified email" const SHOPIFY_CLIENT_ID = 'cl_7f3a9d2e' exports.onExecutePostLogin = async (event, api) => { if (event.client.client_id !== SHOPIFY_CLIENT_ID) return if (event.user.email_verified) return api.access.deny( 'Please verify your email address before signing in to the store. Check your inbox for the verification link.' ) } ``` The reason is recorded in the tenant [logs](../logs.md) next to the user, which is where support will look first. ## Sync customer data into Shopify Shopify can copy claims from the ID token into the customer profile on every sign-in. Enable it in **Settings → Customer accounts → Third-party identity provider**: turn on **Sync customer data**, choose the **overwrite rules** (keep existing values, or replace them with what the token says), and request the extra scopes in **Additional scopes**. | Shopify field | Claim | Scope to add | Faable Auth user field | | -------------------- | -------------------------------- | ------------ | ---------------------------------------------------------------------------- | | First name | `given_name` | `profile` | `given_name` | | Last name | `family_name` | `profile` | `family_name` | | Phone | `phone_number` (E.164) | `phone` | `phone` — store it as `+34600111222` | | Default address | `address` (OIDC object) | `address` | `address.street_address`, `.locality`, `.region`, `.postal_code`, `.country` | | Tags | `urn:shopify:customer:tags` | — | Set from an Action, see below | | Additional addresses | `urn:shopify:customer:addresses` | — | Set from an Action, see below | Faable Auth fills the standard claims from the user profile, filtered by scope exactly like `/userinfo`. Keep the user record in shape with the management API: ```http POST /user/user_9c2d1e6a4f8b Authorization: Bearer Content-Type: application/json { "given_name": "Jane", "family_name": "Doe", "phone": "+34600111222", "address": { "street_address": "Calle Mayor 1", "locality": "Madrid", "region": "MD", "postal_code": "28013", "country": "ES" } } ``` Shopify maps `region` to `province_code` (ISO 3166-2) and `country` to `country_code` (ISO 3166-1 alpha-2). It validates everything before writing. > [!WARNING] > **Claim import is all-or-nothing.** A phone that is not valid E.164, a country that is not a two-letter code, or HTML in any value makes Shopify skip the whole import for that sign-in (it retries next time and logs a timeline event on the customer). Nothing breaks the login itself, but the profile silently stays stale. ### Tags and extra addresses with an Action The two `urn:shopify:customer:*` claims are not OIDC standard claims, so they come from an Action that reads your own data and sets them on the ID token. This example keeps them in the user's `app_metadata`, which only your backend can write: ```js // Action: "Shopify customer enrichment" const SHOPIFY_CLIENT_ID = 'cl_7f3a9d2e' exports.onExecutePostLogin = async (event, api) => { // Only the storefront client needs these claims. if (event.client.client_id !== SHOPIFY_CLIENT_ID) return const meta = event.user.app_metadata || {} // Comma-separated string. Replaces ALL tags on the customer when // Shopify's overwrite rule is "replace". const tags = [] if (meta.loyalty_tier) tags.push(`loyalty-${meta.loyalty_tier}`) if (meta.wholesale) tags.push('wholesale') if (event.stats && event.stats.is_new_user) tags.push('signup-web') if (tags.length) { api.idToken.setCustomClaim('urn:shopify:customer:tags', tags.join(', ')) } // Array in Shopify's Admin API address format. Treated as a group: an // empty array clears every address, so only send it when you mean it. if ( Array.isArray(meta.shipping_addresses) && meta.shipping_addresses.length ) { api.idToken.setCustomClaim( 'urn:shopify:customer:addresses', meta.shipping_addresses.slice(0, 5).map((a, i) => ({ first_name: event.user.given_name, last_name: event.user.family_name, address1: a.line1, address2: a.line2, city: a.city, province_code: a.region, zip: a.postal_code, country_code: a.country, phone: a.phone, default: i === 0 })) ) } } ``` The resulting ID token carries, next to the standard claims: ```json { "urn:shopify:customer:tags": "loyalty-gold, wholesale", "urn:shopify:customer:addresses": [ { "first_name": "Jane", "last_name": "Doe", "address1": "Calle Mayor 1", "city": "Madrid", "province_code": "MD", "zip": "28013", "country_code": "ES", "default": true } ] } ``` Things to know about custom claims in Faable Auth: - Claim names may contain letters, digits, `_ . : / -`, so the `urn:shopify:…` names are fine. An Action can never rewrite the user's identity: the real `email`, `email_verified`, `given_name` and other profile claims always win over a same-named custom claim, and setting a reserved name (`sub`, `aud`, `nonce`, `sid`, `acr`, …) throws and fails the login. - Limits per token: **10 custom claims and 2 KB**. A long addresses array is the only realistic way to hit them, hence the `slice(0, 5)`. - Claims set at login are frozen into the refresh token and re-emitted on every refresh. Shopify re-imports on **sign-in**, so a changed tag reaches the customer profile the next time the shopper signs in, not on a background refresh. - A later `api.access.deny()` in the chain discards the claims together with the login. ## Signing out Shopify implements **RP-Initiated Logout**. When a shopper logs out of the storefront, Shopify sends the browser to your `end_session_endpoint`: ```http GET https://login.acme.com/logout ?id_token_hint= &post_logout_redirect_uri=https://account.acme.com/logout_callback &state= ``` Faable Auth ends the session, then redirects to `post_logout_redirect_uri` with `state` echoed back. That URL must **exactly match** an entry in the client's **Logout URLs** — no prefix matching, no wildcards. If it does not, the shopper sees a `400` page from Faable Auth instead of the store. It is the single most common thing to forget, and it only shows up the first time a customer signs out. Logging out of the store does not sign the shopper out of your other applications unless they share the Faable Auth session cookie on the same domain and you use [Front-Channel Logout](../oidc/logout.md) there. Shopify itself does not support back-channel or front-channel logout, so a session ended elsewhere is not pushed to the storefront; the storefront session simply ends when the refresh token expires, or at the next refresh if you [suspend the user](../suspend-users.md) in Faable Auth. ## Performance: the one-second rule Shopify aborts the sign-in if your **token**, **discovery** or **userinfo** endpoint takes longer than **one second** to respond. Faable Auth answers these in tens of milliseconds, and your [Actions](../extensibility/actions.md) do not run on them: they run while the shopper is still on your login page, before the redirect back to Shopify. Long-running Actions make the login page slower, never the token exchange. What can push you over the limit is something in front of Faable Auth: a custom domain routed through a slow proxy, a WAF challenge on `/oauth/token`, or a certificate problem. If sign-ins fail intermittently with a Shopify-side error and nothing shows in your tenant logs, time the endpoints from outside: ```bash curl -o /dev/null -s -w '%{time_total}\n' https://login.acme.com/.well-known/openid-configuration ``` ## Changing domains later If you move the Shopify customer accounts domain, or move Faable Auth to a custom domain, do it in this order: 1. Add the **new** Callback URL and Logout URL to the Faable Auth client while keeping the old ones. 2. Change the domain in Shopify (for a Faable domain change: update the Discovery URL so the issuer changes too). 3. Test a sign-in and a sign-out. 4. Remove the old URLs. Changing the Faable Auth hostname changes the `iss` of every new token. Shopify re-reads discovery, but sessions issued under the old issuer are invalid afterwards, so expect every shopper to sign in once more. ## Headless storefronts If you run a headless storefront (Hydrogen or your own frontend using the Customer Account API), the identity provider connection still applies: Shopify's hosted `account.acme.com` login is what sends shoppers to Faable Auth. Buyers authenticated through your identity provider also need to be authenticated in checkout; follow Shopify's checkout authentication guidance for headless stores. Faable Auth needs no extra configuration for that. ## Troubleshooting | Symptom | Cause and fix | | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | Shopify rejects the provider at setup | Discovery URL unreachable, or the issuer does not match byte for byte (trailing slash, `www.`, `http` vs `https`). Curl it as shown above. | | The connection option is missing in Shopify | The store is not on Shopify Plus. | | "Test connection" redirects but ends in an error | The signed-in user has `email_verified: false`. Sign in with a verified user or a passwordless connection. | | A customer signs in but Shopify shows an error | Same cause: `email_verified` is not `true` for that user. Check `GET /user/:id`. | | `invalid_client` at token exchange | Wrong Client Secret, or Client ID pasted with whitespace. | | `invalid_grant` at token exchange | The code was already used or expired, or `redirect_uri` differs from the registered Callback URL. Usually a stale tab; retry. | | Sign-out shows a `400` page | Shopify's `post_logout_redirect_uri` is not in the client's **Logout URLs** (exact match). | | Shoppers are signed out after 30 days | Refresh token lifetime is the 30-day default. Raise it to `7776000` in the client's **Refresh token** settings. | | Names or phone do not appear on the customer | `profile` / `phone` not in **Additional scopes**, **Sync customer data** off, or the import was skipped because one value failed validation. | | Tags or addresses do not appear | The Action is disabled, guards on the wrong `client_id`, or the addresses array exceeded the 2 KB claim budget. Check the `action.claims` rows in logs. | | A customer is linked to the wrong account | Open the customer in Shopify admin and **Unlink** the provider ID; the next sign-in re-links by email. | | Sign-in fails intermittently, nothing in Faable Auth logs | An endpoint exceeded Shopify's one-second timeout. Check what sits in front of your custom domain. | ## Limitations - Shopify supports **RP-Initiated Logout** only. Back-channel and front-channel logout are not used, so a session ended elsewhere is not pushed to the storefront. - Implicit and hybrid flows are unsupported on both sides. Authorization code only. - Customers who sign in through an external identity provider **cannot change their email from the Shopify account page**. Email changes happen in Faable Auth (see [Change Email](../change-email.md)); a customer already linked by `sub` keeps their Shopify account. - One Faable Auth client per store. ## Reference: Faable Auth endpoints Shopify uses | Purpose | URL | | ------------- | --------------------------------------------------------- | | Discovery | `https://login.acme.com/.well-known/openid-configuration` | | JWKS | `https://login.acme.com/.well-known/jwks.json` | | Authorization | `https://login.acme.com/authorize` | | Token | `https://login.acme.com/oauth/token` | | UserInfo | `https://login.acme.com/userinfo` | | Logout | `https://login.acme.com/logout` | Further reading: [Authorization Code Flow](../oauth-flows/authorization-code.md) · [Refresh Token](../oauth-flows/refresh-token.md) · [OpenID Connect](../oidc) · [Actions](../extensibility/actions.md) · [Custom Domain](../custom-domain.md). --- ## Faable Auth vs Auth0, Clerk & Keycloak Source: https://faable.com/docs/auth/compare # Faable Auth vs Auth0, Clerk & Keycloak **Short answer:** Faable Auth is a managed, multi-tenant identity server built on OAuth 2.0 and OpenID Connect — comparable to Auth0 or Clerk, but **hosted 100% in Europe with full GDPR data sovereignty**, with **no infrastructure for you to run** (unlike self-hosted Keycloak), and bundled in the **same subscription as Faable Deploy** with **simple MAU-based pricing** and **support from the engineers who run it**. Each option below is a strong identity solution; this page helps you pick by priorities. ## Quick comparison | Capability | Faable Auth | Auth0 | Clerk | Keycloak | | ---------------------------------------- | ----------------------------------- | -------------------------------- | ------------- | --------------------- | | OAuth 2.0 / OIDC standards | ✅ | ✅ | ✅ | ✅ | | Social login (Google, GitHub, Microsoft) | ✅ | ✅ | ✅ | ✅ | | Passwordless (magic link / OTP) | ✅ | ✅ | ✅ | Via extensions | | Machine-to-machine (M2M) tokens | ✅ | ✅ | Limited | ✅ | | Multi-tenant by design | ✅ | Add-on / Organizations | Organizations | Realms (you model it) | | Extensibility (custom code in the flow) | ✅ Actions + Webhooks | ✅ Actions | Webhooks | SPIs (Java) | | Fully managed (no servers to run) | ✅ | ✅ | ✅ | ❌ self-hosted | | 100% European hosting + GDPR sovereignty | ✅ | EU region available (US company) | US-based | Wherever you host it | | Bundled with app hosting | ✅ Faable Deploy, same subscription | ❌ | ❌ | ❌ | > Competitor capabilities and tiers change frequently — confirm current details on each vendor's own pricing and docs. ## Other alternatives at a glance | Alternative | What it really is | Pick it when | | ---------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------- | | [Supabase Auth](compare-supabase-auth.mdx) | Auth built into the Supabase backend (Postgres + RLS) | You're already all-in on Supabase | | [AWS Cognito](compare-aws-cognito.mdx) | AWS-native identity, wired into IAM and API Gateway | You're all-in on AWS | | [Firebase Authentication](compare-firebase-auth.mdx) | B2C-oriented identity inside Google's mobile stack | You're shipping a consumer app on Firebase | | [Authelia](compare-authelia.mdx) | Self-hosted SSO portal that gates services behind a reverse proxy | You're protecting self-hosted infrastructure, not building a product | ## When Faable Auth is the better fit - **You need European data sovereignty.** User identities stay in Europe under European law, with full GDPR compliance. - **You don't want to operate auth infrastructure.** Faable Auth is fully managed — no servers, upgrades, or database to run (the main cost of self-hosting Keycloak). - **You want auth and hosting together.** [Faable Deploy](../deploy/get-started.md) and Faable Auth share one [subscription](pricing.md) and one support relationship. - **You want predictable pricing** based on monthly active users (MAU), not per-feature add-ons that escalate at scale. - **Multi-tenancy is first-class** — isolate orgs, teams, and users by host without extra plumbing. ## When another tool might fit better - **Auth0** has the deepest feature surface and ecosystem (now part of Okta); a fit for large enterprises already standardized on it, if EU sovereignty isn't a hard requirement. - **Clerk** offers excellent prebuilt React UI components and developer experience for B2C/B2B apps that are happy on US hosting. - **Keycloak** is the go-to when you specifically want open-source, self-hosted identity and have the team to operate it. - **You need SAML today.** Faable Auth doesn't implement it yet — enterprise connections speak OAuth 2.0 / OIDC only. All three alternatives ship it. ([Two-step verification](mfa.md) is built in, so that one is no longer a gap.) ## How Faable Auth works Four concepts model the whole product: **Accounts** (your tenant), **Connections** (sources of users), **Clients** (your apps), and **Users & Teams**. Add login with the [`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js) SDK or a [framework quickstart](quickstart/nextjs.md), and call the Management API from your backend with client credentials. See **[Get Started with Faable Auth](get-started.md)**, or learn the concept first in [What is a multi-tenant identity server?](what-is-a-multi-tenant-identity-server.mdx). --- ## Faable Auth vs AWS Cognito Source: https://faable.com/docs/auth/compare-aws-cognito # Faable Auth vs AWS Cognito **Short answer:** Faable Auth and Amazon Cognito are both managed identity providers built on OAuth 2.0 and OpenID Connect, but they target different teams. Cognito fits organizations already deep in the AWS ecosystem who accept its configuration complexity. Faable Auth is a **multi-tenant identity server with a simpler developer experience**, **hosted 100% in Europe with full GDPR data sovereignty**, **bundled with [Faable Deploy](../deploy/get-started.md) hosting**, and **no AWS account or IAM to manage**. ## Quick comparison | Capability | Faable Auth | AWS Cognito | | ---------------------------------------- | ----------------------------------- | ---------------------------------------------- | | OAuth 2.0 / OIDC standards | ✅ | ✅ | | Social login (Google, GitHub, Microsoft) | ✅ | ✅ | | Passwordless (magic link / OTP) | ✅ | Limited (custom flows) | | Enterprise SSO / SAML | ❌ not yet | ✅ | | MFA / 2FA | ✅ TOTP + passkeys | ✅ | | Machine-to-machine (M2M) tokens | ✅ | ✅ (app clients) | | Multi-tenant by design | ✅ | User pools (you model isolation) | | Developer experience | ✅ Focused dashboard + SDK | Powerful but complex (IAM, pools, app clients) | | 100% European hosting + GDPR sovereignty | ✅ | Region-configurable within AWS | | Ecosystem lock-in | None (standards-based) | Tied to AWS | | Bundled with app hosting | ✅ Faable Deploy, same subscription | ❌ | > Competitor capabilities and tiers change frequently — confirm current details on each vendor's own pricing and docs. ## When Faable Auth is the better fit - **You're not committed to AWS** and don't want to manage IAM, user pools, and app clients to ship login. - **You need European data sovereignty** with a straightforward GDPR posture rather than configuring AWS regions and data-processing terms. - **You want auth and hosting together** — [Faable Deploy](../deploy/get-started.md) and Faable Auth share one [subscription](pricing.md). - **You value developer experience** — a focused [dashboard](https://dashboard.faable.com) and [SDK](https://www.npmjs.com/package/@faable/auth-js) over assembling Cognito primitives. ## When AWS Cognito might fit better - You're **all-in on AWS** and want identity that integrates natively with other AWS services (IAM roles, AppSync, API Gateway authorizers). - You need Cognito-specific features tightly coupled to the AWS control plane. - You need **SAML today** — Faable Auth has no SAML connector yet (MFA and passkeys are built in). ## Related - [Faable Auth vs Auth0, Clerk & Keycloak](compare.mdx) - [Faable Auth vs Supabase Auth](compare-supabase-auth.mdx) · [vs Firebase Authentication](compare-firebase-auth.mdx) · [vs Authelia](compare-authelia.mdx) - [What is a multi-tenant identity server?](what-is-a-multi-tenant-identity-server.mdx) - [Get Started with Faable Auth](get-started.md) --- ## Faable Auth vs Firebase Authentication Source: https://faable.com/docs/auth/compare-firebase-auth # Faable Auth vs Firebase Authentication **Short answer:** Faable Auth and Firebase Authentication both make it easy to add login to an app, but Firebase is optimized for B2C mobile/web apps inside Google's ecosystem, while Faable Auth is a **standards-based, multi-tenant OAuth 2.0 / OpenID Connect identity server** built for B2B and multi-tenant SaaS. Faable Auth is **hosted 100% in Europe under GDPR**, has **first-class machine-to-machine tokens**, and is **bundled with [Faable Deploy](../deploy/get-started.md)** — without tying you to Google Cloud. ## Quick comparison | Capability | Faable Auth | Firebase Authentication | | ---------------------------------------- | ----------------------------------- | --------------------------------- | | OAuth 2.0 / OIDC standards | ✅ | Partial (Google-centric tokens) | | Social login (Google, GitHub, Microsoft) | ✅ | ✅ | | Passwordless (magic link / OTP) | ✅ | ✅ (email link, phone) | | Enterprise SSO / SAML | ❌ not yet | ✅ (Identity Platform tier) | | MFA / 2FA | ✅ TOTP + passkeys | ✅ | | Machine-to-machine (M2M) tokens | ✅ | ❌ (not the focus) | | Multi-tenant by design | ✅ | Tenants in Identity Platform tier | | 100% European hosting + GDPR sovereignty | ✅ | Google Cloud regions | | Ecosystem lock-in | None (standards-based) | Tied to Firebase/Google Cloud | | Bundled with app hosting | ✅ Faable Deploy, same subscription | Firebase Hosting (separate model) | > Competitor capabilities and tiers change frequently — confirm current details on each vendor's own pricing and docs. ## When Faable Auth is the better fit - **You're building B2B or multi-tenant SaaS** and want [multi-tenancy](what-is-a-multi-tenant-identity-server.mdx) and [M2M tokens](clients.md) as first-class features. - **You need European data sovereignty** under GDPR rather than Google Cloud regional settings. - **You want standards portability** — plain OAuth 2.0 / OIDC you can move, not Firebase-specific SDKs. - **You want auth and hosting in one [subscription](pricing.md)** with [Faable Deploy](../deploy/get-started.md). ## When Firebase Authentication might fit better - You're building a **consumer mobile app** already using Firebase (Firestore, Cloud Messaging, Analytics) and want identity that plugs straight into that stack. - You want Firebase's client SDKs and realtime database tightly integrated. - You need **SAML today** — Faable Auth has no SAML connector yet (MFA and passkeys are built in). ## Related - [Faable Auth vs Auth0, Clerk & Keycloak](compare.mdx) - [Faable Auth vs AWS Cognito](compare-aws-cognito.mdx) · [vs Supabase Auth](compare-supabase-auth.mdx) · [vs Authelia](compare-authelia.mdx) - [Get Started with Faable Auth](get-started.md) --- ## Faable Auth vs Supabase Auth Source: https://faable.com/docs/auth/compare-supabase-auth # Faable Auth vs Supabase Auth **Short answer:** Faable Auth and Supabase Auth are both managed authentication services, but Supabase Auth is designed as part of the Supabase backend (Postgres + APIs), while Faable Auth is a **standalone, multi-tenant OAuth 2.0 / OpenID Connect identity server** you can put in front of any stack. Faable Auth is **hosted 100% in Europe under GDPR**, offers **first-class machine-to-machine tokens**, and is **bundled with [Faable Deploy](../deploy/get-started.md)** — without requiring you to adopt a specific database. ## Quick comparison | Capability | Faable Auth | Supabase Auth | | ---------------------------------------- | ----------------------------------- | ------------------------------ | | OAuth 2.0 / OIDC standards | ✅ | ✅ (GoTrue) | | Social login (Google, GitHub, Microsoft) | ✅ | ✅ | | Passwordless (magic link / OTP) | ✅ | ✅ | | Enterprise SSO / SAML | ❌ not yet | ✅ (Pro/Team tiers) | | MFA / 2FA | ✅ TOTP + passkeys | ✅ | | Machine-to-machine (M2M) tokens | ✅ | Limited | | Multi-tenant by design | ✅ | You model it in Postgres / RLS | | Standalone (any database/stack) | ✅ | Coupled to Supabase Postgres | | 100% European hosting + GDPR sovereignty | ✅ | Region-configurable | | Bundled with app hosting | ✅ Faable Deploy, same subscription | Part of the Supabase platform | > Competitor capabilities and tiers change frequently — confirm current details on each vendor's own pricing and docs. ## When Faable Auth is the better fit - **You want identity independent of your database.** Faable Auth sits in front of any backend; you're not adopting Postgres + RLS to get auth. - **You're building multi-tenant B2B SaaS** and want [multi-tenancy](what-is-a-multi-tenant-identity-server.mdx) and [M2M tokens](clients.md) without extra plumbing. - **You need European data sovereignty** under GDPR. - **You want auth and hosting together** via [Faable Deploy](../deploy/get-started.md) in one [subscription](pricing.md). ## When Supabase Auth might fit better - You're **already building on Supabase** (Postgres, storage, edge functions) and want auth tightly integrated with row-level security in the same database. - You want the all-in-one Supabase developer experience for a single-tenant app. - You need **SAML today** — Faable Auth has no SAML connector yet (MFA and passkeys are built in). ## Related - [Faable Auth vs Auth0, Clerk & Keycloak](compare.mdx) - [Faable Auth vs AWS Cognito](compare-aws-cognito.mdx) · [vs Firebase Authentication](compare-firebase-auth.mdx) · [vs Authelia](compare-authelia.mdx) - [Get Started with Faable Auth](get-started.md) --- ## Faable Auth vs Authelia Source: https://faable.com/docs/auth/compare-authelia # Faable Auth vs Authelia **Short answer:** they solve different problems. [Authelia](https://www.authelia.com/) is a **self-hosted single sign-on portal** that sits next to a reverse proxy and puts a login gate in front of services that have no authentication of their own — the classic homelab and internal-tooling use case. Faable Auth is a **managed, multi-tenant OAuth 2.0 / OpenID Connect identity server** that your own application integrates with, so _your users_ sign up and log in to _your product_. If you're protecting Grafana, Sonarr or an internal dashboard behind Traefik, Authelia is likely the better tool. If you're shipping a SaaS with its own user base, Authelia isn't designed for that. ## Quick comparison | Capability | Faable Auth | Authelia | | ---------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | | Deployment | Fully managed | Self-hosted (you run it, plus Redis/DB) | | Primary job | Identity for apps you build | Gate in front of apps you deploy | | OAuth 2.0 / OIDC provider | ✅ | ✅ (OpenID Certified™) | | Forward auth for a reverse proxy | ❌ | ✅ (its core feature) | | User store | Managed, with a user API | LDAP, or a YAML file | | Self-service signup | ✅ | ❌ (users are provisioned by an admin) | | Social login (Google, GitHub, Microsoft) | ✅ | ❌ | | Passwordless (magic link / OTP) | ✅ | ❌ as a first factor | | MFA / 2FA | ✅ TOTP, WebAuthn/passkeys | ✅ TOTP, WebAuthn/passkeys, Duo push | | SAML | ❌ not yet | ❌ (on their roadmap) | | Machine-to-machine (M2M) tokens | ✅ | Limited | | Multi-tenant by design | ✅ | ❌ single instance, single directory | | RBAC / Teams | ✅ | Access-control rules per resource | | Extensibility in the login flow | ✅ [Actions](extensibility/actions.md) + [Webhooks](extensibility/webhooks.md) | Configuration-driven rules | | Cost | [Included from the Free plan](pricing.md) | Free & open source — you pay in infra + ops | | 100% European hosting + GDPR sovereignty | ✅ | Wherever you host it | > Competitor capabilities change frequently — confirm current details in [Authelia's own docs](https://www.authelia.com/) and roadmap. ## When Faable Auth is the better fit - **You're building a product with its own users.** Sign-up, email verification, password reset, social login and a user API come with it; Authelia expects users to already exist in LDAP or a config file. - **You're multi-tenant.** Each Faable [account](what-is-a-multi-tenant-identity-server.mdx) is an isolated tenant with its own clients, users and signing keys. Authelia is one instance, one directory. - **You don't want to run identity infrastructure.** No container, no Redis, no session store, no upgrade window, no PostgreSQL to back up. - **You need machine-to-machine tokens** for service-to-service calls — [Client Credentials](oauth-flows/client-credentials.md) against a registered [API](apis.md). - **You need European data sovereignty** without operating the servers yourself. ## When Authelia might fit better - **You're protecting self-hosted services behind a reverse proxy.** Forward auth with Traefik, nginx or Caddy is exactly what Authelia is for, and Faable Auth does not do it. - **You want Duo push or per-route access rules.** Authelia has both; Faable Auth ships TOTP and WebAuthn/passkeys but no push provider, and it is an identity provider rather than a reverse-proxy gate. - **Everything must stay on your own hardware**, with no third party involved at any point. - **Your user directory is already LDAP** and you want to keep it as the source of truth. ## Can they coexist? Yes, and it's a reasonable setup: Authelia in front of your internal infrastructure, Faable Auth for the product your customers log into. They don't compete for the same requests. ## Related - [Faable Auth vs Auth0, Clerk & Keycloak](compare.mdx) — the closest comparison if you're weighing self-hosted Keycloak too. - [Faable Auth vs Supabase Auth](compare-supabase-auth.mdx) · [vs AWS Cognito](compare-aws-cognito.mdx) · [vs Firebase Authentication](compare-firebase-auth.mdx) - [What is a multi-tenant identity server?](what-is-a-multi-tenant-identity-server.mdx) - [Get Started with Faable Auth](get-started.md) --- ## Sign in with Face ID, Touch ID or a Passkey in Next.js Source: https://faable.com/docs/auth/guides/nextjs-passkeys import { Callout } from 'nextra/components' # Sign in with Face ID or a passkey in Next.js By the end of this guide your Next.js app signs users in with **Face ID, Touch ID, Windows Hello or a hardware security key** — a passkey — and never sees a password. Returning users get the passkey offered from inside the email field, the way Stripe or GitHub do it. You will also gate one sensitive action behind a fresh second factor. The surprising part: **your application contains no WebAuthn code.** The ceremony runs on Faable Auth's hosted screen, and there is a good reason it has to — explained below. ## What you need - A Next.js app already signing in with Faable Auth. If not, do the [Next.js quickstart](../quickstart/nextjs.mdx) first — this guide starts where it ends: a `faableauth` client in `lib/faable.ts` and `SessionContextProvider` around your tree. - `@faable/auth-js` and `@faable/auth-helpers-react` at a version that ships `getAal()` / `useAal()` (2.5 or later). - A tenant on the **Hobby** plan or above, for the policy step at the end. ## 1. Turn passkeys on In the [Faable Dashboard](https://dashboard.faable.com), open your auth account: 1. **Login Experience → Passkeys** — switch on **Sign in with a passkey**. The hosted login screen now shows a "Continue with a passkey" button at the top. 2. **Security → Two-step verification** — set the mode to **Optional** for now. Users who register a passkey get to use it; everyone else is unaffected. **Set the Relying Party ID before anyone enrols.** A passkey is bound to the domain it was created on. If your login lives at `acme.auth.faable.link` today and moves to `auth.acme.com` tomorrow, every passkey stops working. In **Security → Two-step verification → WebAuthn Relying Party ID**, put a domain you own (`acme.com`): passkeys then work across every host under it, and the move costs nothing later. ## 2. The sign-in button Nothing changes. The button you already have sends the user to `/authorize`; the hosted screen does the rest: ```tsx // components/SignInButton.tsx 'use client' import { faableauth } from '@/lib/faable' export function SignInButton() { return ( ) } ``` On the hosted screen a user who has a passkey sees two things: the **Continue with a passkey** button, and — on Chrome, Safari and Edge — the same passkey suggested from inside the email field as soon as it gets focus (WebAuthn _conditional UI_). Either way, Face ID or Touch ID comes up, and the browser lands back on your callback with an authorization code exactly as a password login would. ### Why the ceremony is not in your app A WebAuthn credential is scoped to an **origin**. The passkey is created on `acme.auth.faable.link` (or your custom domain), so only pages served from that origin can ask the authenticator to sign with it. Your app on `app.acme.com` cannot — not with `@simplewebauthn/browser`, not with `navigator.credentials` directly. The browser refuses before any code of yours runs. That is why Faable hosts the enrolment and the challenge on the auth domain, and why this guide has no `credentials.get()` in it. The upside is that your app does not carry the WebAuthn dependency, the browser-specific autofill wiring, or the security-key edge cases; the hosted screen already does. ## 3. Let users register a passkey Users add passkeys on Faable's hosted security page, from a signed-in browser. Link to it from your account settings: ```tsx // components/SecurityLink.tsx const AUTH_DOMAIN = 'https://acme.auth.faable.link' // the same `domain` as your client export function SecurityLink() { return ( Security — add a passkey or an authenticator app ) } ``` The page lists what they have, offers **Add a security key or passkey** (Face ID / Touch ID / Windows Hello / a hardware key) and **Add an authenticator app**, and lets them remove either. The user has to be signed in on the auth domain for it to load, which they are right after logging in through your app. A link in your settings gets you single-digit enrolment. What actually gets users a passkey is the offer shown **right after they sign in** with a password or a code, before they reach your app — turn it on under **Login Experience → Passkeys**, next to passkey sign-in. It never blocks the login, and your callback handler does not change. See [Inviting users to create a passkey](../login-experience.md#inviting-users-to-create-a-passkey). The hosted page is served from the auth origin — that is not a limitation, it is the point. Registering a passkey from `app.acme.com` would bind it to `app.acme.com`, where the login screen never runs. ## 4. Show how they signed in Every session's access token says how it was authenticated ([RFC 8176](https://datatracker.ietf.org/doc/html/rfc8176) `amr`) and to what assurance level (`acr`). The React helpers read them locally, no request: ```tsx // components/SignedInWith.tsx 'use client' import { useAal, useHasAmr } from '@faable/auth-helpers-react' export function SignedInWith() { const aal = useAal() const passkey = useHasAmr('hwk') if (aal === 0) return null return (

{passkey ? 'Signed in with Face ID / passkey' : 'Signed in with a password'} {aal === 2 ? ' · two-step verified' : ''}

) } ``` A passkey that verified the user (Face ID, Touch ID, a PIN) is **two factors in one gesture**, so those sessions arrive at `aal === 2` straight away and are never asked for a second step. A bare hardware key without user verification is one factor. ## 5. Step-up before a sensitive action Changing payment details, deleting an organisation, exporting data: ask for a fresh second factor first, without changing the policy for everyone. ```tsx // components/DeleteOrganisation.tsx 'use client' import { faableauth } from '@/lib/faable' import { useAal } from '@faable/auth-helpers-react' export function DeleteOrganisation({ onDelete }: { onDelete: () => void }) { const aal = useAal() const handleClick = () => { if (aal < 2) { // Comes back to this same page with aal === 2 once Face ID / the code // checks out. Users with nothing enrolled are taken through enrolment. faableauth.stepUp({ redirectTo: window.location.href }) return } onDelete() } return } ``` `stepUp()` sends the browser through `/authorize` with `acr_values=urn:faable:loa:2` and `prompt=login`, so the server asks for a second factor even when it would not have, and even though a single-factor session exists. When the user lands back, the token carries `acr: urn:faable:loa:2` and the button works. Trust the **server**, not the hook, for the action itself. `useAal()` decodes the token without verifying it — good for hiding a button, not for authorising a delete. Your API should check the `acr` claim on the validated access token before it acts (see [Validate Access Tokens](../validate-access-tokens.mdx)). ## 6. Require it for everyone (optional) Once your users have had a chance to enrol, switch **Security → Two-step verification** to **Required**. From then on every login asks; anyone with nothing enrolled is taken through enrolment during the login instead of being locked out. Users who lose their device get back in with a recovery code, or by an administrator removing the factor from their user page. If your app also signs users in with the passwordless **OTP grant** — codes exchanged from your own UI rather than the hosted screen — read [Direct grants](../mfa.md#direct-grants) first: that path gets a `403 mfa_required` you have to handle with `signInWithMfa()`. ## Testing locally WebAuthn only allows a non-HTTPS origin on `localhost`. The hosted screens run on the auth domain over HTTPS, so **local development of your Next.js app works unchanged** — the ceremony never runs on `localhost:3000`, it runs on `acme.auth.faable.link`. To try Face ID without a phone, Chrome's DevTools has a virtual authenticator: **More tools → WebAuthn → Enable virtual authenticator environment**, add an authenticator with _internal_ transport and _user verification_ on. Register a passkey from the hosted security page, sign out, and the next sign-in offers it from the email field. ## Where to go next - [Two-Step Verification](../mfa.md) — the full reference: modes, recovery codes, tokens, direct grants - [Custom Domain](../custom-domain.md) — read the Relying Party ID warning above before moving - [Validate Access Tokens](../validate-access-tokens.mdx) — checking `acr` on the server --- ## Migrate from Auth0 to Faable Auth Source: https://faable.com/docs/auth/guides/migrate-from-auth0 # Migrate from Auth0 to Faable Auth **Short answer:** Migrating from Auth0 to Faable Auth is mostly a mapping exercise, because both are standards-based OAuth 2.0 / OpenID Connect identity servers. You recreate your Auth0 _tenant_ as a Faable _Account_, your _Applications_ as _Clients_, and your _Connections_ (social, database, enterprise) one-to-one, then export your users from Auth0 and import them into Faable. Your app code changes only the issuer URL, client ID, and endpoints — the OAuth flows stay the same. The main reasons teams move are **European data sovereignty (full GDPR)**, **predictable MAU-based pricing**, and **bundling auth with [Faable Deploy](../../deploy/get-started.md) hosting** in one subscription. ## Concept mapping | Auth0 | Faable Auth | Notes | | --------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | Tenant | [Account](../what-is-a-multi-tenant-identity-server.mdx) | Your isolated identity boundary, at `https://.auth.faable.link` | | Application | [Client](../clients.md) | SPA, native, regular web, or M2M | | Connection (social/DB/enterprise) | [Connection](../connections.md) | `database`, `social`, `passwordless`, `oidc` categories | | Organizations | Teams within an account | Multi-tenant by design | | Actions / Rules / Hooks | [Actions](../extensibility/actions.md) + [Webhooks](../extensibility/webhooks.md) | Custom code in the login flow and async events | | Management API | [Management API](../apis.md) | Called from your backend with client credentials | | Universal Login | Universal Login | Hosted login screen, same concept | | M2M Application | M2M Client (Client Credentials) | Service-to-service tokens | ## Migration steps 1. **Create your Account.** Sign up in the [Faable Dashboard](https://dashboard.faable.com/account/register) and note your issuer URL (`https://.auth.faable.link`, or a [custom domain](../custom-domain.md)). 2. **Recreate Connections.** For each Auth0 connection, create the equivalent Faable [Connection](../connections.md) — reuse the same upstream OAuth client IDs/secrets for [social providers](../social/google.md) so users keep the same accounts. 3. **Recreate Clients.** For each Auth0 Application, create a [Client](../clients.md) of the matching type and copy over your **Allowed Callback URLs**, **Logout URLs**, and **Web Origins**. 4. **Port your custom logic.** Translate Auth0 Actions/Rules into Faable [Actions](../extensibility/actions.md) (in-flow) and [Webhooks](../extensibility/webhooks.md) (async, e.g. sync to your CRM). 5. **Export and import users.** Export your users from Auth0 (Management API or a bulk export job). Password hashes can be imported so users don't need to reset; social-only users re-link automatically on first login through the same provider. 6. **Update your app.** Point your SDK or OIDC client at the new issuer, client ID, and endpoints. With [`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js) or a [framework quickstart](../quickstart/nextjs.md) this is a few config values. 7. **Cut over.** Run both in parallel behind a feature flag if you want zero downtime, then flip traffic to Faable and decommission the Auth0 tenant. ## Things that change (and things that don't) - **No change:** the OAuth 2.0 Authorization Code (+ PKCE) and Client Credentials flows, ID/Access token semantics, and your overall login UX. - **Changes:** the issuer/domain, client IDs/secrets, and the Management API base URL and payloads (similar shape, not identical). ## Related - [Faable Auth vs Auth0, Clerk & Keycloak](../compare.mdx) - [Get Started with Faable Auth](../get-started.md) · [Clients](../clients.md) · [Connections](../connections.md) - [Authorization Code Flow](../oauth-flows/authorization-code.md) --- ## Migrate from Clerk to Faable Auth Source: https://faable.com/docs/auth/guides/migrate-from-clerk # Migrate from Clerk to Faable Auth **Short answer:** Migrating from Clerk to Faable Auth means moving from Clerk's component-first model to a standards-based OAuth 2.0 / OpenID Connect identity server. You recreate your Clerk _instance_ as a Faable _Account_, your _application_ as one or more _Clients_, and your social/email connections one-to-one, then export your users from Clerk and import them into Faable. The biggest practical difference is the integration style: instead of Clerk's prebuilt React components you use standard OAuth flows via [`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js) or a [framework quickstart](../quickstart/nextjs.md). Teams typically move for **European data sovereignty (GDPR)**, **standards portability (no vendor-specific SDK lock-in)**, and **bundling with [Faable Deploy](../../deploy/get-started.md)**. ## Concept mapping | Clerk | Faable Auth | Notes | | -------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------- | | Instance (dev/prod) | [Account](../what-is-a-multi-tenant-identity-server.mdx) | Your isolated identity boundary | | Application | [Client](../clients.md) | SPA, native, regular web, or M2M | | Social connection / Email & password | [Connection](../connections.md) | `social`, `database`, `passwordless` | | Organizations | Teams within an account | Multi-tenant by design | | Prebuilt components (``, ``) | Universal Login + your UI | Hosted login screen; build your own UI with the SDK | | Webhooks | [Webhooks](../extensibility/webhooks.md) | Async events to your backend | | Backend API / JWT verification | [APIs](../apis.md) + standard OIDC JWTs | Verify tokens against the JWKS endpoint | ## Migration steps 1. **Create your Account** in the [Faable Dashboard](https://dashboard.faable.com/account/register) and note your issuer URL. 2. **Recreate Connections** for each Clerk login method — email/password as a [database connection](../connections.md), social as [social connections](../social/google.md), and [magic link / OTP](../passwordless.mdx) as passwordless. 3. **Create your Client(s).** Most Clerk apps map to a single SPA or regular-web [Client](../clients.md); copy your redirect/callback URLs. 4. **Replace components with flows.** Swap Clerk's React components for the standard [Authorization Code + PKCE](../oauth-flows/authorization-code.md) flow using the SDK. Hosted Universal Login covers the login UI; you keep full control of the rest of your app. 5. **Export and import users.** Export users from Clerk's Backend API and import them into Faable, including password hashes where available so users don't reset passwords. 6. **Verify tokens the standard way.** Replace Clerk's session helpers with standard OIDC JWT verification against Faable's JWKS endpoint (built into the SDK and [quickstarts](../quickstart/nextjs.md)). 7. **Cut over** behind a flag, then decommission the Clerk instance. ## Things that change (and things that don't) - **No change:** your underlying need — sign-up, login, sessions, social login — all supported. - **Second factor:** [two-step verification](../mfa.md) is built in (authenticator apps, security keys and passkeys), but enrolments do not migrate — users re-enrol on first login under a `required` policy. - **Changes:** you move from Clerk-specific components to standard OAuth/OIDC flows, which removes SDK lock-in but means building (or restyling) some UI. ## Related - [Faable Auth vs Auth0, Clerk & Keycloak](../compare.mdx) - [Get Started with Faable Auth](../get-started.md) · [Clients](../clients.md) · [Connections](../connections.md) - [Quickstart: Next.js](../quickstart/nextjs.md) --- ## Migrate from Keycloak to Faable Auth Source: https://faable.com/docs/auth/guides/migrate-from-keycloak # Migrate from Keycloak to Faable Auth **Short answer:** Migrating from self-hosted Keycloak to Faable Auth removes the servers, upgrades, and database you currently operate, while keeping the same OAuth 2.0 / OpenID Connect standards. You map each Keycloak _realm_ to a Faable _Account_, your Keycloak _clients_ to Faable _Clients_, and your _identity providers_ to Faable _Connections_, then export your users and import them into Faable. The motivation is almost always the same: **stop operating identity infrastructure** while staying **100% in Europe under GDPR** — Faable Auth is fully managed, so there is nothing to patch, scale, or back up. ## Concept mapping | Keycloak | Faable Auth | Notes | | ------------------------------- | -------------------------------------------------------- | --------------------------------------------- | | Realm | [Account](../what-is-a-multi-tenant-identity-server.mdx) | Your isolated identity boundary | | Client | [Client](../clients.md) | SPA, native, regular web, or M2M | | Identity Provider (social/OIDC) | [Connection](../connections.md) | OAuth 2.0 / OIDC only — no SAML yet | | User federation (LDAP/AD) | [Connection](../connections.md) over OIDC | No direct LDAP bind — federate via OAuth/OIDC | | Authentication SPI (Java) | [Actions](../extensibility/actions.md) | Custom in-flow logic, no Java/SPI to maintain | | Admin REST API | [Management API](../apis.md) | Called with client credentials | | Self-hosted servers + DB | Fully managed | No infrastructure for you to run | ## Migration steps 1. **Create your Account** in the [Faable Dashboard](https://dashboard.faable.com/account/register) and note your issuer URL. 2. **Map realms to accounts.** Each Keycloak realm becomes a Faable [Account](../what-is-a-multi-tenant-identity-server.mdx); if you ran one realm per customer, model those as teams/accounts. 3. **Recreate Connections.** Translate each Keycloak Identity Provider into a Faable [Connection](../connections.md) (social or generic [OIDC](../connections.md)), reusing upstream client credentials. 4. **Recreate Clients** with the same redirect URIs, web origins, and flow types. 5. **Replace SPIs with Actions.** Move custom authenticator SPIs to [Actions](../extensibility/actions.md) — JavaScript in the flow instead of compiled Java extensions. 6. **Export and import users.** Use Keycloak's export (`realm-export` / Admin REST API) to extract users and credentials, then import into Faable. Password hashes can be carried over so users don't reset. 7. **Decommission your Keycloak deployment** once traffic is on Faable — and reclaim the operational time you spent running it. ## What you stop maintaining - Server provisioning, JVM tuning, and clustering - Database backups, migrations, and version upgrades - Security patching of the Keycloak runtime - High-availability and scaling for login spikes ## Related - [Faable Auth vs Auth0, Clerk & Keycloak](../compare.mdx) - [What is a multi-tenant identity server?](../what-is-a-multi-tenant-identity-server.mdx) - [Get Started with Faable Auth](../get-started.md) · [Connections](../connections.md) --- ## Clients Source: https://faable.com/docs/auth/clients # Clients In Faable Auth, a **Client** represents an application that authenticates users or requests authorization to access APIs: a Single Page Application (React, Vue, Angular), a native mobile app, a server-side web application, or a backend machine-to-machine service. Clients are what initiate the OAuth 2.0 and OpenID Connect flows. You manage them from the [Faable Dashboard](https://dashboard.faable.com), or create them programmatically with [Dynamic Client Registration](#dynamic-client-registration). ## Creating a Client Registering a client generates two credentials: - **Client ID** — a public, unique identifier. Safe to ship in client-side code (React, Expo); it tells Faable Auth which application is asking. - **Client Secret** — a confidential string that proves the application's identity. _Never expose it in a SPA, a mobile binary, or anything a user can read._ It belongs to backends only. ## Types of Clients Your architecture dictates the configuration and the flow: | Client type | Where the code runs | Secret? | Flow | | ---------------------- | ------------------------------------------------ | ------- | -------------------------------------------------------------- | | **Single Page App** | The browser (React, Vue, Angular) | ❌ | [Authorization Code + PKCE](oauth-flows/authorization-code.md) | | **Native / Mobile** | The user's device (iOS, Android, React Native) | ❌ | [Authorization Code + PKCE](oauth-flows/authorization-code.md) | | **Regular Web App** | Your server (Express, Next.js server components) | ✅ | [Authorization Code](oauth-flows/authorization-code.md) | | **Machine to Machine** | A backend service or worker, no human involved | ✅ | [Client Credentials](oauth-flows/client-credentials.md) | | **Device / TV / CLI** | An input-constrained device | ❌ | [Device Code](oauth-flows/device-code.md) | Faable Auth enforces **PKCE with `S256`** — it advertises no other code challenge method — so public clients are covered whether or not a secret is present. ## Client Configuration ### Security boundaries - **Allowed Callback URLs (`callbacks`)** — the whitelist of URLs Faable Auth may redirect to after login (e.g. `https://myapp.com/callback`). Prevents open-redirect abuse. - **Allowed Logout URLs (`logout_urls`)** — where users may be sent after [RP-Initiated Logout](oidc/logout.md). - **Allowed Web Origins (`web_origins`)** — origins (`scheme://host[:port]`, **no path** — e.g. `http://localhost:5173`) permitted to make cross-origin calls to the token and passwordless endpoints. **When the list is empty, any origin is allowed**; fill it in to lock browser calls down to your own app. Your account's auth domain is always allowed implicitly, so you only list your application origins. - **Connections** — which authentication methods (Google, passwordless, database…) this specific client may use. Set on each [connection](connections.md) via its _Enabled clients_ list. ### Token lifetimes | Setting | Effect | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | | `refresh_token.token_lifetime` | Lifetime of issued refresh tokens, in seconds. **Default 2592000** (30 days). | | Access token lifetime | Not a client setting — it comes from the [API](apis.md) the token is issued for (`token_lifetime`), defaulting to 24 hours. | > `refresh_token.expiration_mode` and `refresh_token.infinite_token_lifetime` exist on the client object for Auth0 compatibility, but are **not enforced today** — only `token_lifetime` governs how long a refresh token lives. Don't rely on them to build a never-expiring session. ### Client authentication at the token endpoint `POST /oauth/token` accepts three methods, as published in your tenant's [discovery document](oidc): | `token_endpoint_auth_method` | How the client authenticates | For | | ---------------------------- | -------------------------------------------------------------- | ----------------------------- | | `client_secret_basic` | HTTP Basic header with `client_id:client_secret` (the default) | Confidential clients | | `client_secret_post` | `client_id` + `client_secret` in the form body | Confidential clients | | `none` | No secret — the PKCE `code_verifier` is the proof | Public SPA and native clients | ### Application metadata Optional fields, mostly consumed by consent screens and by tooling: `client_uri`, `logo_uri`, `tos_uri`, `policy_uri`, `contacts`, `application_type` (`web` | `native`), `software_id`, `software_version`. ### Front-Channel Logout A client may register a `frontchannel_logout_uri`: Faable loads it in a hidden iframe when the user logs out of the OP, so your application can clear its own session. Set `frontchannel_logout_session_required` to `true` and Faable includes `iss` and `sid` on that call, letting you scope the cleanup to the exact session. See [Logout](oidc/logout.md). ## Dynamic Client Registration Clients can be created over HTTP instead of through the dashboard, per [OIDC Dynamic Client Registration 1.0 / RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591). This is what lets a third-party tool onboard itself against your tenant — MCP clients, IDE integrations and OAuth debuggers commonly expect it. The endpoint is published in your discovery document as `registration_endpoint`: ```bash curl -X POST 'https://your-domain.auth.faable.link/oidc/register' \ -H 'content-type: application/json' \ -d '{ "client_name": "My Tool", "redirect_uris": ["https://tool.example.com/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "client_secret_basic" }' ``` Response (`201`), per RFC 7591 §3.2.1 — this is the only time the secret is returned: ```json { "client_id": "…", "client_secret": "…", "client_id_issued_at": 1751641200, "client_secret_expires_at": 0, "redirect_uris": ["https://tool.example.com/callback"], "client_name": "My Tool", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "client_secret_basic", "application_type": "web" } ``` Notes on behaviour: - **`redirect_uris` is the only required field.** Everything else defaults per spec: `grant_types` to `["authorization_code"]`, `response_types` to `["code"]`, `token_endpoint_auth_method` to `client_secret_basic`, `application_type` to `web`. - **Unsupported metadata is rejected**, not silently accepted. Requesting a grant type Faable doesn't implement returns `400`. Supported grants are `authorization_code`, `refresh_token`, `client_credentials`, `urn:ietf:params:oauth:grant-type:device_code`, `urn:ietf:params:oauth:grant-type:token-exchange` and Auth0's passwordless OTP grant. - **`client_secret_expires_at` is `0`** — issued secrets don't expire. - **The client is bound to the tenant resolved from the request host**, so register against the domain you actually want it to live in. - **`web_origins`** is accepted as a non-standard extra, with the same rules as in the dashboard. - **Registration is not itself authenticated.** Anyone who can reach your auth domain can create a client in your tenant — a new client grants no access on its own, but if that's not a trade-off you want, keep an eye on `oauth.client.register` entries in your [logs](logs.md). ## Next Steps - **[Connections](connections.md)** — the identity providers you attach to a client. - **[Authorization Code Flow](oauth-flows/authorization-code.md)** — the mechanics of the standard login flow. - **[APIs](apis.md)** — register the resource servers your clients request tokens for. - **[Quickstarts](quickstart/nextjs.md)** — jump into working code, including [FastAPI](quickstart/fastapi.md) on the backend side. --- ## Connections Source: https://faable.com/docs/auth/connections # Connections In Faable Auth, a **Connection** represents a source of users. It defines how your users will authenticate when logging into your application. Connections are the core building blocks that enable different authentication methods without having to write custom integrations for each one of them. You can configure and manage all your connections directly from the **Faable Dashboard**. ## Types of Connections Every connection has a `connection_type`. These are all of them: | `connection_type` | Category | What it is | | -------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `database` | `database` | Email + password. Faable Auth stores and manages the credentials on your behalf. | | `google_oauth2` | `social` | [Google](social/google.md) sign-in. Endpoints preconfigured. | | `github` | `social` | [GitHub](social/github.md) sign-in. Endpoints preconfigured. | | `microsoft` | `social` | [Microsoft / Entra ID](social/microsoft.md) — work, school and personal Microsoft accounts. Endpoints preconfigured. | | `figma` | `social` | Figma sign-in. Endpoints preconfigured; bring your own OAuth app. | | `custom` | `social` | Any other OAuth 2.0 provider — you supply the authorize, token and userinfo URLs. See [Facebook](social/facebook.md) for a worked example. | | `passwordless_email` | `passwordless` | [Magic link or one-time code](passwordless.md) by email. The address itself is the identity. | | `oidc` | `oidc` | An external OIDC **issuer** whose JWTs are trusted in a [Token Exchange](oauth-flows/token-exchange.md) — GitHub Actions being the canonical case. Machine-to-machine, not a browser login. | Those four **categories** (`database`, `social`, `passwordless`, `oidc`) are what the dashboard sidebar and the API filter on: `GET /connections?category=social`. > **Not available yet:** SMS one-time codes, SAML, and any pre-built enterprise SSO connector (Okta, Entra ID as an _enterprise_ connection rather than a social one). For an OIDC-compliant corporate IdP, a `custom` connection covers browser login today, as long as its userinfo endpoint returns `id`, `name` and `email`. ## Using Connections for OAuth Login When a developer uses Faable Auth to implement a login flow (such as the standard OAuth2 Authorization Code flow), the concept of a connection is crucial. 1. **Creation:** First, you create and configure a Connection in the Faable Dashboard (e.g., you create a Google social connection and provide your Google Client ID and Secret). Each connection is assigned a unique name. 2. **Authentication Request:** When your application redirects the user to the Faable Auth `/authorize` endpoint to log in, you can optionally include the `connection_id` parameter in the URL. - If you specify a specific connection (e.g., `connection_id=connection_abc123`, shown in the dashboard), Faable Auth will directly redirect the user to that provider's login page, bypassing the generic login screen. (The legacy `connection` parameter, which takes the connection name, is still accepted as a deprecated alias.) - If you don't specify a connection, Faable Auth will display the Universal Login screen, presenting the user with options for all the connections that are enabled for your client application (e.g., an email/password form alongside a "Log in with Google" button). 3. **Unified Profile:** Regardless of the connection used to log in, Faable Auth normalizes the user data. It handles the specific handshake with the external provider and returns a standard set of OAuth2/OIDC tokens (Access Token, ID Token) to your application. This means your application's logic remains exactly the same whether the user logged in with a password, a magic link, or their GitHub account. ## Next Steps Now that you understand what Connections are, you can learn how to integrate them into your application by exploring the following topics: - **[Clients](clients.md):** Learn how to register your front-end application or backend API to use these connections. - **[OAuth 2.0 Flows](oauth-flows):** Choose the right flow for your application type. - **[Authorization Code Flow](oauth-flows/authorization-code.md):** Understand the standard OAuth2 flow used to redirect users to Faable Auth and handle the login callback. - **[Social Login](social):** Set up Google, GitHub or Microsoft sign-in from one place. - **[Passwordless](passwordless.md):** Magic links and one-time codes, no password to remember. - **[Quickstart Next.js](quickstart/nextjs.md):** Jump straight into the code and see a full authentication implementation in action. - **[Quickstart React Native](quickstart/react-native.md):** Jump straight into the code and see a full authentication implementation in action. --- ## Social Login Source: https://faable.com/docs/auth/social # Social Login **Social login lets your users sign in with an account they already have — Google, GitHub, Microsoft — instead of creating a new password.** In Faable Auth, each provider is a [social connection](/auth/connections): you register an app with the provider, paste its client ID and secret into the [Faable Dashboard](https://dashboard.faable.com), and enable the connection on your [client](/auth/clients). Faable handles the OAuth handshake and returns standard OIDC tokens, so your app code is identical regardless of which provider the user chose. ## Built-in providers These have their upstream endpoints preconfigured — you only supply credentials. | Provider | Connection type | Shared Faable app | Setup guide | | -------------------- | --------------- | ----------------- | --------------------------------------------- | | Google | `google_oauth2` | ✅ | [Configure Google](/auth/social/google) | | GitHub | `github` | ✅ | [Configure GitHub](/auth/social/github) | | Microsoft (Entra ID) | `microsoft` | ✅ | [Configure Microsoft](/auth/social/microsoft) | | Figma | `figma` | — | Bring your own OAuth app | **Shared Faable app** means you can switch the connection on without registering anything with the provider — useful to get moving, though the consent screen then shows Faable rather than your company. Fill in your own Client ID and Secret to take it over. ## Any other provider For anything not in the table above, create a **Custom OAuth2** connection (`custom`) and supply the upstream values yourself: **Authorize URL**, **Token URL**, **User Info URL** and your credentials. Faable runs the same Authorization Code flow against them. The one constraint: the provider's userinfo response must expose the profile under the keys `id`, `name`, `email` and `picture`, as **strings**. Custom connections use the generic mapper — there is no per-provider normalization — so a provider that returns `user_id`, or nests the avatar in an object, lands with those fields empty. `id` is the one that must be present; without it the login fails outright. Worked examples: [Facebook](/auth/social/facebook). Providers that require a non-standard client authentication scheme — notably [Sign in with Apple](/auth/social/apple), which needs the client secret to be a signed ES256 JWT — are **not** supported through this path yet. Don't confuse this with the `oidc` connection type, which is a different thing entirely: it registers an external OIDC **issuer** whose JWTs Faable will trust in a [Token Exchange](/auth/oauth-flows/token-exchange) — GitHub Actions being the canonical case. It drives no browser login, which is why the dashboard files it under **Machine to Machine** rather than Social Login. ## How it works 1. **Register an app** with the provider (e.g. a Google OAuth client) and copy its client ID and secret. 2. **Create a social [connection](/auth/connections)** in the Faable Dashboard and paste those credentials. 3. **Enable the connection** on the [client](/auth/clients) that runs your login flow. 4. **Log in.** Send users to the `/authorize` endpoint — they pick a provider on the Universal Login screen, or you can target one directly with `connection_id=connection_abc123`. Faable normalizes the result into standard OAuth 2.0 / OIDC tokens. ## Related - [Connections](/auth/connections) · [Clients](/auth/clients) - [Authorization Code Flow](/auth/oauth-flows/authorization-code) - [Get Started with Faable Auth](/auth/get-started) --- ## Google Social Login Source: https://faable.com/docs/auth/social/google ## Google Social Login Google is a popular social connection that allows users to log in to your application using their Google profile. To set up a Google social connection, you must: 1. Create Google OAuth credentials with the Google Auth Platform. 1. Configure and test a Google social connection with the FaableAuth Dashboard. ## Prerequisites Before you begin: 1. [Sign up for a Google Developer account](https://console.developers.google.com/). 1. [Create a Google Project](https://support.google.com/googleapi/answer/6251787?ref_topic=7014522) ## Google Auth Platform The Google Auth Platform helps you manage your applications and OAuth credentials for logging in and calling Google APIs. To learn more, read [Get started with the Google Auth Platform](https://support.google.com/cloud/answer/15544987?hl=en). Use the Google Auth Platform to: 1. Configure Google consent screen 1. Create Google OAuth 2.0 Client Detailed instructions for each step are provided in the sections below. ### Configure Google consent screen > If your application requests sensitive OAuth scopes or uses a custom image, Google limits it to 100 logins until the OAuth consent screen is verified. Consent screen verification may take up to several days. When you use OAuth 2.0 for authorization, your application requests authorization for one or more scopes of access from a Google Account. Google displays a consent screen to the user, including a summary of your project, its policies, and the requested access scopes. Before creating an OAuth client ID, you must first configure the OAuth consent screen with information about your application. In the Google Cloud Console, [configure your Google OAuth consent screen](https://developers.google.com/workspace/guides/configure-oauth-consent): 1. Navigate to Google Auth Platform > Branding: For Authorized domains, enter `faable.auth.link`. If you’re using a custom domain, enter your custom domain instead. 1. Navigate to Google Auth Platform > Audience: For User type, select Make External. In Test Users, you can add the email addresses you want to use for testing. 1. Navigate to Google Auth Platform > Data Access to add or remove scopes. To learn more, read OAuth 2.0 Scopes for Google APIs. 1. Follow the rest of the instructions to finish configuring your Google OAuth consent screen. 1. Select Save Changes. ### Create Google OAuth 2.0 Client To create a Google OAuth 2.0 Client, you need your FaableAuth domain, which you can find in the FaableAuth Dashboard. 1. Navigate to **Auth → Settings → Custom Domains**. 1. If you haven’t configured a custom domain, your FaableAuth domain name is `{YOUR_FAABLEAUTH_NAME}.auth.faable.link`. Your redirect URI is `https://{YOUR_FAABLEAUTH_NAME}.auth.faable.link/callback`. In the Google Cloud Console, [create a new OAuth 2.0 Client](https://console.cloud.google.com/auth/clients/create): 1. Navigate to Google Auth Platform > Clients. Then, select New Client. 1. For the Application type, select Web application. 1. Enter the following information for your OAuth 2.0 Client: - Name: The name of your OAuth 2.0 Client. - Authorized Javascript origins: `https://{YOUR_DOMAIN}` - Authorized redirect URIs: `https://{YOUR_DOMAIN}/callback` 1. Select Create. ## Create the connection in FaableAuth Once you have the Client ID and Client Secret from Google: 1. In the FaableAuth Dashboard, navigate to **Auth → Social Login** and click **Create**. 1. Choose **Google** as the connection type. 1. Enter the **Client ID** and **Client Secret** generated by Google. 1. Save the connection. --- ## GitHub Social Login Source: https://faable.com/docs/auth/social/github ## GitHub Social Login The GitHub social connection allows users to log in to your application using their GitHub account. It's a popular choice for developer-facing products, since most engineers already have a GitHub identity and trust the OAuth consent screen. Faable Auth implements the standard [GitHub OAuth 2.0 Authorization Code flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps). After the user grants consent on `github.com`, GitHub redirects back to Faable Auth's `/callback` endpoint with an authorization code. Faable Auth exchanges that code for an access token, fetches the user's profile from `https://api.github.com/user`, normalizes it into a standard user object, and finally issues your application its own OAuth2/OIDC tokens. By default, Faable Auth syncs the GitHub profile attributes (id, name, login, email, avatar) into the user record on every login. Changes you make on GitHub are reflected in Faable Auth the next time the user signs in. ## Prerequisites Before you begin: 1. [Sign up for a GitHub account](https://github.com/signup) (a personal account is enough, but an organization-owned OAuth App is recommended for production). 2. Decide whether the connection will live under a personal account or a GitHub Organization. Organization-owned OAuth Apps make ownership transfer and team-based secret management easier. ## Find your Faable Auth domain You will need your Faable Auth domain to register the redirect URI on GitHub. 1. In the Faable Auth Dashboard, navigate to **Auth → Settings → Custom Domains**. 2. If you haven't configured a custom domain, your Faable Auth domain is `{YOUR_FAABLEAUTH_NAME}.auth.faable.link`. 3. Your redirect URI is: ``` https://{YOUR_DOMAIN}/callback ``` If you're using a custom domain (e.g. `auth.yourcompany.com`), use that instead. The path is always `/callback` — it's a single, account-wide callback handled by Faable Auth, not by your application. Your application's own redirect URI (the one registered on the Faable Auth Client) is invoked later, after Faable Auth completes the upstream OAuth handshake with GitHub. ## Register a GitHub OAuth App GitHub offers two flavors of integrations: **OAuth Apps** and **GitHub Apps**. For social login you want a classic **OAuth App** — GitHub Apps target fine-grained API access, not user sign-in. 1. Sign in to GitHub and navigate to [**Settings > Developer settings > OAuth Apps**](https://github.com/settings/developers) (for a personal app) or to your organization's settings and then **Developer settings > OAuth Apps** (for an org-owned app). 2. Click **New OAuth App** (or **Register a new application**). 3. Fill in the following fields: | Field | Value to Provide | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | | **Application name** | A user-facing name shown on the GitHub consent screen (e.g. `Acme Corp`). | | **Homepage URL** | Your product's public URL (e.g. `https://acme.com`). | | **Application description** | Optional. Shown on the consent screen — keep it short and accurate. | | **Authorization callback URL** | `https://{YOUR_DOMAIN}/callback` — must match the Faable Auth callback exactly, including scheme and no trailing slash. | | **Enable Device Flow** | Leave unchecked. Faable Auth uses the Authorization Code flow for browser-based login. | 4. Click **Register application**. 5. On the application detail page, copy the **Client ID** (always visible) and click **Generate a new client secret** to obtain the **Client Secret**. Store the secret immediately — GitHub will only display it once. 6. Optional: upload an application logo. It appears on the consent screen and can improve trust. > If you register the OAuth App under a GitHub Organization that enforces **OAuth App access restrictions**, organization owners must approve the app before members can sign in with it. Until approval, sign-in attempts from those members will fail at the GitHub consent step. ## Create the connection in Faable Auth Once you have the Client ID and Client Secret from GitHub: 1. In the Faable Auth Dashboard, navigate to **Auth → Social Login** and click **Create**. 2. Choose **GitHub** as the connection type. 3. Fill in the fields: | Field | Value to Provide | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | **Connection name** | An internal identifier, e.g. `github`. This is the value your application will pass as the `connection` query parameter on `/authorize`. | | **Client ID** | The Client ID generated by GitHub. | | **Client Secret** | The Client Secret generated by GitHub. Stored encrypted at rest. | | **Scope** | Optional. Defaults to `user:email`. Add additional scopes (space-separated) only if your application needs more — see [Scopes](#scopes) below. | | **Enabled clients** | Optional. Restrict which Faable Auth Clients (applications) can use this connection. Leave empty to enable it for every Client in the account. | | **Enabled** | Toggle on to make the connection live. | 4. Save the connection. Faable Auth ships with the upstream URLs preconfigured, so you do **not** need to enter them manually: - **Authorize URL:** `https://github.com/login/oauth/authorize` - **Token URL:** `https://github.com/login/oauth/access_token` - **User Info URL:** `https://api.github.com/user` - **Response type:** `code` These defaults are merged with whatever you set on the connection — if you ever need to point at a GitHub Enterprise Server instance, you can override them on a per-connection basis. ## Scopes The default scope is **`user:email`**. This is the minimum required for Faable Auth to retrieve the user's primary email address (which is needed to identify and dedupe accounts). You can extend the scope list to request additional GitHub permissions, but only do so if your application actually consumes them. Common additions: | Scope | What it grants | | ------------ | -------------------------------------------------------------------------------- | | `user:email` | Read access to the user's email addresses (default). | | `read:user` | Read access to the full profile (bio, location, etc.). | | `read:org` | List the organizations the user belongs to. | | `repo` | Full control of private repositories. **Do not request unless strictly needed.** | A full list is in the [GitHub OAuth scopes reference](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps). Scopes you request on the connection are merged with any scopes your application passes through the `scope` parameter on `/authorize`; duplicates are de-duplicated automatically. ## User profile mapping After the upstream handshake completes, Faable Auth fetches the GitHub profile from `https://api.github.com/user` and normalizes it into the internal `StandarizedUser` shape: | Faable user field | Source on GitHub response | Notes | | ----------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `id` (cast to string) | GitHub returns a numeric id; Faable stores it as a string for compatibility with other providers. | | `name` | `name` if present, otherwise `login` | If the user hasn't set a display name on GitHub, the username (`login`) is used as a fallback. | | `email` | `email` | Requires the `user:email` scope. May be `null` if the user has no public email and only private ones — Faable will pick up the verified primary email via the userinfo response. | | `picture` | `avatar_url` | URL to the user's GitHub avatar. | Faable Auth identifies a returning user by the upstream `id` plus the connection — so renames on GitHub (changing `login`) do not orphan accounts, but a different `id` (e.g. switching to a different GitHub user) is treated as a different identity. ## Trigger a login Once the connection is enabled, your application can redirect users straight to GitHub by including the `connection` query parameter on the standard `/authorize` request: ``` https://{YOUR_DOMAIN}/authorize ?client_id={YOUR_CLIENT_ID} &response_type=code &redirect_uri={YOUR_APP_CALLBACK} &scope=openid%20profile%20email &state={RANDOM_STATE} &connection=github ``` If you omit `connection`, the Universal Login screen is shown and GitHub appears as one of the available sign-in options (provided the connection is enabled for the Client). See [OAuth Flows: Authorization Code](../oauth-flows/authorization-code.md) for the full request reference. ## Test the connection 1. From the Faable Auth Dashboard, open your GitHub connection and click **Try Connection** (or trigger a login from your app with `connection=github`). 2. You'll be redirected to GitHub's authorization page showing the application name, requested scopes, and a **Authorize** button. 3. After authorizing, GitHub redirects back to `https://{YOUR_DOMAIN}/callback`. Faable Auth exchanges the code, fetches the profile, provisions or updates the user, and finally redirects to your application's `redirect_uri` with an authorization code (or tokens, depending on the response type you requested). 4. Confirm in the Dashboard's **Users** view that the new user exists and that the identity is linked to the `github` connection. ## Revoke or rotate credentials - **Rotate the client secret:** Generate a new secret on GitHub, paste it into the Faable Auth connection's **Client Secret** field, and save. Existing user sessions are unaffected — only new sign-in attempts need the updated secret. - **Revoke a user's grant:** GitHub users can revoke access at any time from [github.com/settings/applications](https://github.com/settings/applications). Doing so does not log them out of your app — Faable Auth's session remains valid until it expires — but they will need to re-consent the next time they sign in via GitHub. - **Delete the OAuth App:** Removes access for everyone. Disable the Faable Auth connection at the same time so users aren't routed to a broken upstream. ## Troubleshooting | Symptom | Likely cause | | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `redirect_uri_mismatch` from GitHub | The **Authorization callback URL** on the GitHub OAuth App doesn't match `https://{YOUR_DOMAIN}/callback` exactly (scheme, host, path, trailing slash). | | `Cannot fetch accessToken` error from Faable Auth | Client ID / Client Secret on the connection don't match the values on GitHub, or the secret has been regenerated and not updated in Faable Auth. | | Login succeeds but `email` is empty on the user | The connection is missing the `user:email` scope, or the user has no verified email on GitHub. Re-check the connection scope list and the user's GitHub email settings. | | Organization members hit a "this app needs approval" page on GitHub | The GitHub organization enforces OAuth App access restrictions. An organization owner must approve the OAuth App. | | `Provider mapper cannot obtain a valid id for user` | The `/user` response is missing `id`. Almost always means the access token request actually returned an error body — check the auth service logs. | --- ## Microsoft (Entra ID) Social Login Source: https://faable.com/docs/auth/social/microsoft ## Microsoft (Entra ID) Social Login The Microsoft social connection lets users sign in with a **Microsoft account** — both work/school identities from **Microsoft Entra ID** (formerly Azure AD) and personal accounts (`outlook.com`, `hotmail.com`, Xbox). Faable Auth talks to the Microsoft identity platform on the `common` authority, which accepts both, so a single connection type covers what other providers split into two. Faable Auth runs the standard [Microsoft identity platform Authorization Code flow](https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow). After consent, Microsoft redirects back to Faable Auth's `/callback`, which exchanges the code for a token, fetches the profile from `https://graph.microsoft.com/oidc/userinfo`, normalizes it, and issues your application its own OAuth 2.0 / OIDC tokens. ## Two ways to enable it | | **Faable's shared app** | **Your own Entra app** | | ------------------------- | --------------------------------------------------------------- | ----------------------------------------------- | | Setup | Toggle the connection on. No Azure portal. | Register an app in Entra ID, paste credentials. | | Consent screen shows | Faable | Your company name | | Redirect URI at Microsoft | `https://faable.auth.faable.link/callback` (already registered) | `https://{YOUR_DOMAIN}/callback` | | Restrict to one directory | ❌ Not possible | ✅ Yes | | Good for | Trying it out, side projects | Production, anything branded | Leaving **Client ID** and **Client Secret** empty puts the connection on Faable's shared app registration; filling them in switches it to yours. The connection's read-only `is_using_default_credentials` field tells you which mode it's in. > **Switching modes changes user identities.** Read [Entra's `sub` is pairwise](#entras-sub-is-pairwise-plan-before-you-switch-apps) before you move a live connection from the shared app to your own. --- ## Option A — Use Faable's shared app 1. In the Faable Dashboard, go to **Auth → Social Login** and click **Create**. 2. Choose **Microsoft** as the connection type. 3. Name the connection (e.g. `microsoft`), leave **Client ID** and **Client Secret** empty, toggle **Enabled** on and save. Sign-in works immediately. Because Google, GitHub and Microsoft all require an **exact, non-wildcard** redirect URI — and per-tenant subdomains cannot all be registered in one app — connections on shared credentials send Microsoft to a single central callback (`https://faable.auth.faable.link/callback`). That host acts as a pure relay: it forwards `?code&state` to your own auth domain, where the session cookie is set. Nothing to configure on your side, but it's why the consent screen says Faable. --- ## Option B — Register your own Entra ID app ### Find your Faable Auth domain 1. In the Faable Dashboard, go to **Auth → Settings → Custom Domains**. 2. Without a custom domain, your Faable Auth domain is `{YOUR_FAABLEAUTH_NAME}.auth.faable.link`. 3. Your redirect URI is: ``` https://{YOUR_DOMAIN}/callback ``` The path is always `/callback` — one account-wide callback handled by Faable Auth, not by your application. Your app's own redirect URI (registered on the Faable Auth [Client](../clients.md)) is invoked later, once Faable finishes the handshake with Microsoft. ### Register the app 1. Open the [Microsoft Entra admin center](https://entra.microsoft.com) → **Identity → Applications → App registrations** → **New registration**. (The same blade exists in the Azure portal.) 2. Fill in: | Field | Value to Provide | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Name** | The name users see on the consent screen (e.g. `Acme Corp`). | | **Supported account types** | _Accounts in any organizational directory and personal Microsoft accounts_ for the widest reach, or _Accounts in this organizational directory only_ to lock sign-in to your company — see [Restricting sign-in](#restricting-sign-in-to-your-own-directory). | | **Redirect URI** | Platform **Web** → `https://{YOUR_DOMAIN}/callback`. Must match exactly: scheme, host, path, no trailing slash. | 3. Click **Register** and copy the **Application (client) ID** from the overview page. 4. Go to **Certificates & secrets → Client secrets → New client secret**. Copy the **Value** (not the Secret ID) immediately — Entra shows it once. 5. Under **API permissions**, confirm the delegated Microsoft Graph permissions `openid`, `profile`, `email`. They are granted by default and are all Faable needs. > **Entra client secrets always expire.** The maximum lifetime is 24 months and there is no "never expires" option. Put the expiry date in your calendar: when the secret lapses, every sign-in through this connection breaks at once with a token-exchange error. ### Create the connection in Faable Auth 1. **Auth → Social Login → Create**, choose **Microsoft**. 2. Fill in: | Field | Value to Provide | | ------------------- | ---------------------------------------------------------------------------------------------------------- | | **Connection name** | Internal identifier, e.g. `microsoft`. | | **Client ID** | The Application (client) ID from Entra. | | **Client Secret** | The secret **Value** from Entra. Stored encrypted at rest. | | **Scope** | Optional. Defaults to `openid profile email` — see [Scopes](#scopes). | | **Enabled clients** | Optional. Restrict which Faable Auth Clients can use this connection. Empty = every Client in the account. | | **Enabled** | Toggle on to make the connection live. | 3. Save. The upstream URLs are preconfigured, so you do **not** enter them manually: - **Authorize URL:** `https://login.microsoftonline.com/common/oauth2/v2.0/authorize` - **Token URL:** `https://login.microsoftonline.com/common/oauth2/v2.0/token` - **User Info URL:** `https://graph.microsoft.com/oidc/userinfo` - **Response type:** `code` --- ## Restricting sign-in to your own directory The `common` authority accepts every Microsoft identity on earth, including personal accounts. To allow only members of your own Entra tenant, override both URLs on the connection, replacing `common` with your **Directory (tenant) ID**: ``` https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/authorize https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token ``` This only works with your own app registration — the shared Faable app is multi-tenant by construction. Set **Supported account types** to _this organizational directory only_ as well, so the restriction is enforced by Microsoft rather than by URL hygiene alone. --- ## Scopes The default scope is **`openid profile email`**: | Scope | What it grants | | --------- | ------------------------------------------------------------------------------ | | `openid` | Required. Without it the `/oidc/userinfo` endpoint rejects the token outright. | | `profile` | The name claims (`name`, `given_name`, `family_name`). | | `email` | The user's email address. | Faable deliberately requests **no Microsoft Graph resource scopes** (`User.Read` and friends). They widen the consent screen for every tenant on the shared app and buy nothing the login flow needs. If your application calls Graph itself, request those scopes from your own app with your own token — don't bolt them onto the login connection. --- ## User profile mapping Faable normalizes the `https://graph.microsoft.com/oidc/userinfo` response into the internal user shape: | Faable user field | Source | Notes | | ----------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `sub` | **Pairwise** — see the warning below. | | `name` | `name`, falling back to `given_name + family_name` | `name` needs the `profile` scope and is absent on some guest accounts. | | `given_name` | `given_name` | | | `family_name` | `family_name` | | | `email` | `email` | Comes from the directory `mail` attribute. | | `picture` | — | **Deliberately dropped.** Graph returns the literal string `https://graph.microsoft.com/v1.0/me/photo/$value`, an endpoint that demands a bearer token. Storing it would render a broken 401 image everywhere the avatar appears. | | `email_verified` | — | **Never set to true.** See below. | ### Entra's `sub` is pairwise — plan before you switch apps Unlike Google's `sub` or GitHub's numeric `id`, Microsoft's `sub` is **unique per (user, app registration)**. The same human signing in through Faable's shared app and through your own app produces two different `sub` values, so Faable sees two different identities. Consequence: moving a live connection from shared credentials to your own app registration makes every existing user look brand new — they get a fresh account instead of their old one. Do it before you have users, or plan an identity migration. ### Why `email_verified` stays false Microsoft does not return `email_verified`, and Entra's `email` comes from the directory `mail` attribute — which a tenant admin can point at a domain they do not own. That's the **nOAuth** class of account-takeover attack: an attacker who controls any Entra tenant sets `mail` to your user's address and, on a server that trusts it, takes over the account. Faable therefore lands Microsoft users with `email_verified: false` and never treats the Microsoft-supplied address as proof of ownership. If you want a verified address, run Faable's own email verification on top. --- ## Trigger a login Send users straight to Microsoft by passing the connection on `/authorize` (the id is shown on the connection in the dashboard): ``` https://{YOUR_DOMAIN}/authorize ?client_id={YOUR_CLIENT_ID} &response_type=code &redirect_uri={YOUR_APP_CALLBACK} &scope=openid%20profile%20email &state={RANDOM_STATE} &connection_id=connection_abc123 ``` Omit `connection_id` and the Universal Login screen appears with Microsoft as one of the options, provided the connection is enabled for that Client. See [Authorization Code Flow](../oauth-flows/authorization-code.md) for the full request reference. ## Rotate or revoke credentials - **Rotate the client secret:** create a new secret in **Certificates & secrets** _before_ the current one expires, paste it into the connection and save. Existing sessions are unaffected; only new sign-ins need the new secret. - **Revoke a user's grant:** users manage this at [myapplications.microsoft.com](https://myapplications.microsoft.com). Revoking does not end their Faable session — that lives until it expires — but they will re-consent on their next Microsoft sign-in. - **Delete the app registration:** cuts off everyone. Disable the Faable connection at the same time so users aren't routed to a broken upstream. ## Troubleshooting | Symptom | Likely cause | | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AADSTS50011: redirect URI mismatch` | The Redirect URI in Entra doesn't match `https://{YOUR_DOMAIN}/callback` exactly. If you're on shared credentials it must instead be Faable's relay — you shouldn't be registering anything at all. | | `AADSTS7000215: Invalid client secret` | The **Secret ID** was pasted instead of the secret **Value**, or the secret expired. | | `AADSTS50020: user account from identity provider does not exist in tenant` | The app is registered as single-tenant but the user belongs elsewhere. Widen **Supported account types**, or keep the restriction if that's the intent. | | Login works but the user has no name | The `profile` scope was removed from the connection, or it's a guest account with no `name` in the directory. | | Users show up as new accounts after a config change | The connection moved between the shared app and your own — `sub` is pairwise. See [above](#entras-sub-is-pairwise-plan-before-you-switch-apps). | | All Microsoft logins break on the same day | The Entra client secret expired. It always does, within 24 months at most. | ## Related - [Social Login overview](../social) · [Connections](../connections.md) · [Clients](../clients.md) - [Authorization Code Flow](../oauth-flows/authorization-code.md) --- ## Facebook Social Login Source: https://faable.com/docs/auth/social/facebook ## Facebook Social Login Facebook is **not a built-in connection type** in Faable Auth — there is no "Facebook" entry in the connection dropdown. You add it as a **Custom OAuth2** connection, supplying Facebook's endpoints yourself. Everything downstream is identical to a built-in provider: same Authorization Code flow, same user record, same tokens issued to your app. This page doubles as the worked example for [Custom OAuth2 connections](../social) in general. ## Prerequisites - A [Facebook Developer account](https://developers.facebook.com/). - Your Faable Auth domain. Without a [custom domain](../custom-domain.md) it's `{YOUR_FAABLEAUTH_NAME}.auth.faable.link`, and your redirect URI is: ``` https://{YOUR_DOMAIN}/callback ``` The path is always `/callback` — one account-wide callback handled by Faable Auth, not by your application. Facebook requires HTTPS here, which Faable domains always are. ## Step 1 — Create the Facebook app 1. In the [Facebook Developer portal](https://developers.facebook.com/apps), create an app and pick the use case that includes **Facebook Login**. 2. Under **Facebook Login → Settings**: | Field | Value to Provide | | --------------------------------- | -------------------------------------------------------------------------------------------------------------- | | **Client OAuth Login** | Enabled. | | **Web OAuth Login** | Enabled. | | **Valid OAuth Redirect URIs** | `https://{YOUR_DOMAIN}/callback` — exact match, no trailing slash. | | **Login with the JavaScript SDK** | Disabled. Faable drives the server-side Authorization Code flow; the JS SDK is a different mechanism entirely. | 3. Under **App settings → Basic**, copy the **App ID** and **App Secret**. 4. Under **App review → Permissions and features**, confirm `public_profile` and `email`. These two are the only permissions granted without Facebook's app review — anything beyond them requires review and, for advanced access, Business Verification. ## Step 2 — Create the Custom OAuth2 connection In the Faable Dashboard, go to **Auth → Social Login → Create** and choose **Custom OAuth2**. Fill in: | Field | Value to Provide | | ------------------- | ---------------------------------------------------------- | | **Connection name** | `facebook` | | **Authorize URL** | `https://www.facebook.com/v21.0/dialog/oauth` | | **Token URL** | `https://graph.facebook.com/v21.0/oauth/access_token` | | **User Info URL** | `https://graph.facebook.com/v21.0/me?fields=id,name,email` | | **Client ID** | Your Facebook **App ID**. | | **Client Secret** | Your Facebook **App Secret**. Stored encrypted at rest. | | **Scope** | `public_profile` `email` | | **Response type** | `code` | | **Enabled** | On. | Pin the Graph API version (`v21.0` above) rather than omitting it — unversioned Graph calls follow Facebook's default version, which moves under you and eventually breaks the connection without any change on your side. Bump it deliberately when you're ready. ### Why the `?fields=` query string matters Facebook's `/me` endpoint returns **only `id` and `name`** unless you ask for more. Faable's generic mapper reads `id`, `name`, `email` and `picture` straight off the userinfo response, so anything you don't request in `fields` simply arrives empty. Requesting `email` is what makes the user's address show up on the Faable user record. `picture` is deliberately left out of the field list: Facebook returns it as a nested object (`{"data": {"url": …}}`), and the mapper only accepts string values — so it would be dropped anyway rather than stored. ## Step 3 — Test it 1. Trigger a login from your app with `connection_id=connection_abc123` (the id is shown on the connection in the dashboard), or use **Try Connection** in the dashboard. 2. Facebook shows its consent screen. While your app is in **Development** mode only users listed under **App roles** can log in — add yourself as a tester, or switch the app to Live. 3. After consent, Facebook redirects to `https://{YOUR_DOMAIN}/callback`; Faable exchanges the code, fetches the profile, provisions the user, and redirects back to your application. 4. Confirm in **Users** that the account exists with an email attached. ## Limitations - **No profile picture.** See above — the mapper takes string fields only. - **`email_verified` is not set.** Facebook doesn't attest verification in this response, so users land unverified. Run Faable's own verification if you need a proven address. - **Users can hide their email.** An account that declined the email permission, or one created with a phone number, produces a user with no address. The login still succeeds — identities are keyed by the provider `id`, not the email. ## Troubleshooting | Symptom | Likely cause | | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `URL Blocked: This redirect failed…` | The redirect URI isn't in **Valid OAuth Redirect URIs**, or differs by a trailing slash. | | `Provider mapper cannot obtain a valid id for user` | The userinfo response has no `id` — usually a wrong **User Info URL**, or a Graph error body returned in place of the profile. | | Login works but the user has no email | `email` is missing from the `?fields=` list, from the connection **Scope**, or the user declined the permission. | | `Invalid Scopes` from Facebook | Faable sends scopes space-separated. If Facebook rejects the value, put the permissions in a single scope entry (`public_profile,email`) so it arrives comma-separated. | | Only some people can log in | The Facebook app is still in Development mode. Only users with an app role can authenticate. | | `Cannot fetch accessToken from connection` | App ID / App Secret mismatch, or the secret was regenerated in Facebook and not updated on the connection. | ## Related - [Social Login overview](../social) — the built-in providers and how Custom OAuth2 works. - [Connections](../connections.md) · [Clients](../clients.md) - [Authorization Code Flow](../oauth-flows/authorization-code.md) --- ## Sign In with Apple Source: https://faable.com/docs/auth/social/apple ## Sign In with Apple > **Not supported yet.** Faable Auth has no Apple connection type, and — unlike [Facebook](facebook.md) — Apple **cannot** be added through a [Custom OAuth2](../social) connection either. This page explains why, so you don't spend an afternoon discovering it the hard way. ## Why Custom OAuth2 doesn't cover Apple Sign in with Apple (SIWA) is OAuth 2.0 shaped, but it departs from the generic flow in three ways that Faable's connection engine doesn't currently handle: 1. **The client secret is a signed JWT, not a string.** Apple requires the `client_secret` sent to its token endpoint to be an **ES256-signed JWT**, built from your Team ID, Key ID and a `.p8` private key, with a maximum lifetime of six months. It has to be regenerated on a schedule. Faable sends the stored client secret verbatim, so there is nothing to sign it with. 2. **There is no userinfo endpoint.** Apple returns the user's identity inside the `id_token` from the token exchange. Faable's flow fetches the profile with a `GET` against a **User Info URL** — a request that has no Apple equivalent, and a connection cannot work without one. 3. **The name arrives once, by `form_post`.** Apple sends `given_name` / `family_name` only on the **very first** authorization, as a `POST` body to your redirect URI, and never again. Faable's `/callback` handles the standard `?code&state` redirect. Point 1 alone is enough to rule it out; the others mean SIWA needs a dedicated code path rather than configuration. ## What to use meanwhile - **[Passwordless](../passwordless.md)** — a magic link or one-time code by email. Closest thing to SIWA's low-friction, no-password experience, and it works fine with the private relay addresses Apple hands out. - **[Google](google.md)** and **[Microsoft](microsoft.md)** — built-in, one toggle to enable. - **Database connection** — plain email + password. ## If you ship on the App Store Apple's App Review guidelines require Sign in with Apple to be offered **only** in apps that use a third-party or social login service as their _sole_ sign-in option (guideline 4.8, "Login Services"). An app whose primary path is email + password, or an email-based passwordless login, generally falls outside that requirement — but the guideline is Apple's to interpret and it changes. Check the [current text](https://developer.apple.com/app-store/review/guidelines/#login-services) before you build your onboarding around it. ## When it lands Adding Apple will need, from the [Apple Developer portal](https://developer.apple.com/account/resources/identifiers/list): - An **App ID** with the _Sign In with Apple_ capability enabled. - A **Services ID** — its identifier becomes the client id (not the App ID's). - A **Sign in with Apple key**, whose `.p8` file signs the client secret; note the **Key ID** and your **Team ID**. - Your Faable Auth callback (`https://{YOUR_DOMAIN}/callback`) registered as a Return URL on the Services ID. Need it? Tell us at [support@faable.com](mailto:support@faable.com) — demand is how this gets prioritized. ## Related - [Social Login overview](../social) — the providers that _are_ supported. - [Passwordless](../passwordless.md) · [Connections](../connections.md) --- ## Passwordless Authentication Source: https://faable.com/docs/auth/passwordless # Passwordless Authentication Passwordless authentication lets a user sign in with just their email — no password to remember. Faable Auth sends either a **magic link** or a **6-digit one-time code (OTP)** to the address, verifies it, and issues tokens. If no user exists for that email yet, one is created on the first successful verification with `email_verified = true` (the click or the code proves ownership of the inbox). The flow is driven by a connection of type `passwordless_email` configured on your tenant. See [Connections](connections.md) for setup. ## Flow overview ```mermaid sequenceDiagram participant User participant App as Your Application participant Faable as Faable Auth User->>App: Enters email App->>Faable: POST /passwordless/start Faable-->>User: Sends email (link or code) rect rgb(240, 240, 240) Note over User, Faable: Option A — OTP User->>App: Enters 6-digit code App->>Faable: POST /oauth/token (grant_type=...passwordless/otp) Faable-->>App: Access, ID, and refresh tokens end rect rgb(230, 230, 230) Note over User, Faable: Option B — Magic link User->>Faable: Clicks link in email Faable-->>App: Redirects to client with tokens end ``` ## Endpoints | Method | Path | Purpose | | ------ | --------------------------------------- | ----------------------------------------------------------------------------------------------- | | `POST` | `/passwordless/start` | Start the flow. Sends a link or a code to the email. | | `GET` | `/passwordless/verify_redirect?token=…` | Public entry point for the magic link. Verifies, issues a session, and redirects to the client. | | `POST` | `/oauth/token` | Exchange an OTP for tokens using the `passwordless/otp` grant. | The auth host follows the standard tenant URL (e.g. `https://your-tenant.auth.faable.link` or your [custom domain](custom-domain.md)). ### Throttle and TTLs - **Send throttle:** `/passwordless/start` deduplicates sends per `(account, email)` for **3 minutes**. A second request inside that window returns success but does not re-send the email. The throttle is cleared automatically when the user completes verification. - **OTP TTL:** **5 minutes** after issue. - **Magic-link TTL:** **1 hour** after issue. > [!NOTE] > Passwordless is available on **every plan, including Free**. On **Hobby** and **Pro** it is unlimited within your MAU allowance; **Free** runs on a fair-use allowance while you build. See [Auth pricing](pricing.md). ## Implementation with `@faable/auth-js` ### Step 1 — Request the email ```ts import { createClient } from '@faable/auth-js' const auth = createClient({ domain: 'your-tenant.auth.faable.link', clientId: '' }) // Magic link await auth.signInWithPasswordless({ email: 'user@example.com', type: 'link' }) // OR — 6-digit code await auth.signInWithPasswordless({ email: 'user@example.com', type: 'code' }) ``` The redirect target after a magic-link click is the **Allowed Callback URL** configured on the client in the Faable dashboard — not a parameter on this call. ### Step 2a — Verify an OTP ```ts const { data, error } = await auth.signInWithOtp({ username: 'user@example.com', otp: '123456' }) ``` On success the SDK stores the session and emits `SIGNED_IN`. `data.user` and `data.session` are populated. ### Step 2b — Magic link The link in the email points to `https://your-tenant.auth.faable.link/passwordless/verify_redirect?token=…`. Faable verifies the token, logs the user in, and redirects to the OAuth callback configured for the client. Your application picks up tokens from that redirect the same way it does for the [Authorization Code flow](oauth-flows/authorization-code.mdx). ## API reference If you are not using the SDK, the same flow is reachable over HTTP. ### Start ```http POST /passwordless/start Content-Type: application/json { "client_id": "cli_…", "email": "user@example.com", "send": "link", "connection_id": "con_…", "auth_params": { "state": "…", "redirect_uri": "https://app.example.com/cb" } } ``` | Field | Required | Default | Description | | --------------- | ----------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `client_id` | yes | — | The client driving the login. | | `email` | yes | — | Recipient address. Validated server-side. | | `send` | no | `"link"` | `link` for a magic link, `code` for a 6-digit OTP. | | `connection_id` | conditional | — | Required when the account has more than one enabled `passwordless_email` connection. If omitted with a single passwordless connection, that one is used. | | `auth_params` | no | — | Extra parameters forwarded to the OAuth callback after verification (e.g. `state`, `redirect_uri`). | Response: ```json { "email": "user@example.com", "email_verified": false } ``` The response shape is intentionally minimal — the email itself is the delivery channel. ### Verify a magic link ```http GET /passwordless/verify_redirect?token=eyJhbGciOi… ``` Returns a `302` to the client's callback with the OAuth response (code or tokens, depending on the client configuration). The endpoint is idempotent on the user record but the underlying ticket is single-use. ### Verify an OTP ```http POST /oauth/token Content-Type: application/json { "grant_type": "http://auth0.com/oauth/grant-type/passwordless/otp", "client_id": "cli_…", "username": "user@example.com", "otp": "123456", "scope": "openid profile email", "audience": "https://api.example.com" } ``` Response on success: ```json { "token_type": "Bearer", "access_token": "…", "id_token": "…", "refresh_token": "…", "expires_in": 3600 } ``` `scope` and `audience` are optional and behave the same as in any other OAuth 2.0 grant. ## User creation On the **first** successful verification for an email, a user is created in the account with `email_verified = true`. On subsequent verifications for an existing user, `email_verified` is forced to `true` (the verification itself is fresh proof). No password is ever set on the user record by this flow. ## Errors | HTTP | When | | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Invalid email, unknown `client_id`, `connection_id` not of type `passwordless_*`, connection disabled, no passwordless connection on the account, or multiple passwordless connections without a `connection_id`. | | `400` | Magic link is malformed, expired, or has already been consumed. | | `401` | OTP is wrong, expired, or already used. | Errors are recorded on the `auth_passwordless_total` metric (labelled `action=send|verify`, `status=success|error`) and on `auth_login_attempts_total` (`method=passwordless`). ## Notification emails A single transactional template, `passwordless.start`, is sent for this flow. It renders one of two variants depending on `send`: - **`passwordless.start.link`** — contains the magic-link button. - **`passwordless.start.code`** — contains the 6-digit code, large and copy-friendly. Both variants are localized (currently English and Spanish), use the tenant's logo and colors, and can be overridden per tenant from the dashboard. ## Next steps - [Connections](connections.md) — create the `passwordless_email` connection driving this flow. - [Authorization Code with PKCE](oauth-flows/authorization-code.mdx) — what happens after a magic-link click hits your callback. - [Logs](logs.md) — audit every send and verification attempt. --- ## Hosted Login UI Source: https://faable.com/docs/auth/hosted-login # Hosted Login UI **You do not have to build the authentication screens for your project.** Sign-in, sign-up, password reset, two-step verification, passkey enrolment, device authorisation — Faable Auth serves all of them on your auth domain, with your logo, and you never write a login form. That is the Hosted Login UI. Your application sends the user to `/authorize` and gets them back authenticated; everything in between is these screens. If you would rather build your own, you can — the same endpoints are there — but everything below is what you would be re-implementing. ## Why they are hosted, and not a form in your app This is not about saving you an afternoon of HTML. Four things only work from the auth domain: **A passkey belongs to the origin that created it.** WebAuthn binds the credential to the domain, so a passkey created on `app.example.com` cannot be used to sign in at `auth.example.com` — or at your second application. Registering them from one place is what makes them work everywhere. **The session is a cookie on the auth domain.** That cookie (`faable_sess`) is what lets a second application skip the login screen entirely: the next `/authorize` finds an existing session and returns without asking for anything. That is single sign-on, and it cannot happen if each app holds its own form. **Credentials never reach your code.** A password is posted from the login screen to the auth server. Your application never sees it, never logs it by accident, and a compromise of your frontend does not become a compromise of your users' passwords. **One place to improve.** Two-step verification, passkeys, rate limits, the suspended-account message, the wording of an expired code — they land on every one of your applications at once, without a release on your side. ## The screens All of them live under `/flow` on your auth domain (`https://.auth.faable.link`, or your [custom domain](custom-domain.md)). | Screen | Path | What it is for | | --------------------- | ---------------------------- | ------------------------------------------------------------- | | Sign in | `/flow/login` | The methods this client offers, in the order you chose | | Email first | `/flow/identify` | Asks for the address before showing a method | | Two-step verification | `/flow/mfa/challenge` | Authenticator app, security key or passkey as a second factor | | Add a second factor | `/flow/mfa/enroll` | Enrolling an authenticator app, with its recovery codes | | Passkey offer | `/flow/passkey/offer` | Shown right after a login, inviting the user to create one | | Forgot password | `/flow/forgot-password` | Requests the reset email | | Reset password | `/flow/reset-password` | Sets the new one, against the tenant's password policy | | Security methods | `/flow/account/security` | Where a signed-in user manages their own factors | | Device activation | `/flow/device-code/activate` | Enters the code a CLI or a TV app is showing | | Device confirmation | `/flow/device-code/confirm` | Names the device and asks the user to authorise it | A user reaching one of these without a session is sent through the login and brought back where they were. ## What your application does Nothing but the redirect. With [`@faable/auth-js`](quickstart): ```ts import { createClient } from '@faable/auth-js' const auth = createClient({ domain: 'https://.auth.faable.link', clientId: '' }) // Sends the browser to the hosted login and returns with a session. await auth.authorize() ``` Or, without the SDK, any OAuth 2.0 / OIDC library pointed at `/authorize` — the screens are the same either way. See [OAuth 2.0 Flows](oauth-flows). ## Making them yours | What | Where | | ------------------------------------------------ | ------------------------------------------------------------------------- | | Logo and icon | **Branding** in the dashboard — used as the header of every screen | | Which methods, in what order, passkeys on or off | [Login Experience](login-experience.md), per account and per client | | The order the screens run in | [Login Flows](login-flows.md) | | Your own domain in the address bar | [Custom Domain](custom-domain.md) | | Language | Served in English or Spanish, chosen from the browser's `Accept-Language` | Because a passkey is bound to its origin, move to a custom domain **before** users start enrolling — or set a WebAuthn Relying Party ID you own. See [Login Experience](login-experience.md#sign-in-with-a-passkey). ## The one screen your users go to on their own `/flow/account/security` is the exception: it is not part of a login, and you can link to it from your own account settings. A user manages their authenticator app, security keys and passkeys there, on the domain those credentials belong to. Link to it with the `client_id` of the application they came from, so a user who arrives without a session is sent back to the right login: ``` https:///flow/account/security?client_id= ``` ## Related - [Login Experience](login-experience.md) — what the sign-in screen offers, and how to change it. - [Two-Step Verification](mfa.md) — the second factor these screens ask for. - [Login Flows](login-flows.md) — the graph the screens walk through. - [Custom Domain](custom-domain.md) — serving them from a domain you own. --- ## Login Experience Source: https://faable.com/docs/auth/login-experience # Login Experience The hosted login screen is not a fixed form. What it shows — which methods, in what order, whether passkeys are on, whether users are invited to create one — is configuration on your auth account, and any client can override it for its own login screen. Everything here lives under **Login Experience** in the dashboard, and under `login_methods` on the [Account and Client](clients.md) objects of the Management API. ## What the login screen shows The screen offers a method when three things are true: the connection is **enabled**, it is **allowed for the client** that started the login (set per connection under _Enabled clients_), and it has not been removed from this login experience. The order is yours. Drag methods in the dashboard, or set `login_methods.order` to a list of connection ids (and the literal `passkey`). Methods you do not list keep the built-in order after the ones you do: passwordless first, then password, then social. Removing a method here is enforced, not cosmetic. A connection taken off a client's login screen is also refused at `/authorize?connection=` for that client — the screen and the server always agree about what a client may use. ## Sign in with a passkey A passkey verified with a biometric or a device PIN proves possession **and** knowledge in one gesture, which is why a passkey login is never asked for a second factor afterwards. Turn it on under **Login Experience → Passkeys**. Two things happen on the login screen. A **Continue with a passkey** button appears under the password form, behind an _or sign in with_ rule, next to the social buttons (you can move it with the method order). And, in browsers that support it, the passkey is also offered from inside the email field — returning users pick it from the autofill suggestions and never touch the button. The button names what the passkey _is_ on that device — **Touch ID** on a Mac, **Face ID** on an iPhone, **Windows Hello** on Windows — with the matching icon, because that is the word the user recognises. The screen asks the browser whether a platform authenticator is present; a machine without one gets the generic label. Underneath it is the same WebAuthn ceremony either way. The same naming appears on the second-factor screen and on the passkey offer. Registration runs entirely on your auth domain: a passkey is bound to the origin that created it, so the ceremony cannot be moved into your own application. Your app only ever sees the resulting session. A passkey is bound to the domain it was created on. If your login moves to a [custom domain](custom-domain.md), every passkey registered on the old domain stops working. Set a **WebAuthn Relying Party ID** to a domain you own (e.g. `example.com`) **before** users start enrolling, and passkeys keep working across every host under it. ## Inviting users to create a passkey Turning passkey sign-in on does not, on its own, get anyone a passkey. Left to a settings page, enrolment stays in the single digits; most passkeys people actually have were created from a prompt shown **right after a login**, at the moment the user has just typed a password and can see why they would rather not. That prompt is the **passkey offer**. With it on, a user who signs in with a password, an email code or a social login — and has no passkey or authenticator app yet — is taken to a hosted screen before returning to your application: It never blocks the login. The user can say **Not now**; a browser that cannot create passkeys skips the screen without showing it; a failed registration offers to continue without one; and a user who simply closes the tab is signed in normally on their next visit. The login is complete before the screen appears — the offer is a question, not a gate. Turn it on under **Login Experience → Passkeys**, next to passkey sign-in. It needs passkey sign-in on: there is no point creating a passkey that cannot be used to sign in. Two knobs keep it from becoming a nag: | Setting | Default | What it does | | ------------------- | ------- | ----------------------------------------------------------------------------------------------------------- | | Ask again after | 30 days | How long to wait before offering again to a user who said _Not now_. | | Maximum times asked | 3 | How many times a user is offered a passkey over their lifetime. `0` switches the offer off for this tenant. | Repeating the prompt measurably raises abandonment. Keep it rare. ### From the API The same settings on the account, for scripts and infrastructure-as-code. `login_methods` is replaced as a whole on write, so send the full object: ```bash curl -X POST https:///account/account_xxx \ -H "Authorization: Bearer $MANAGEMENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "login_methods": { "order": ["connection_abc", "passkey"], "passkey_login_enabled": true, "passkey_promotion": "offer", "passkey_promotion_snooze_days": 30, "passkey_promotion_max_prompts": 3 } }' ``` Nothing changes in your application. The same `redirect_uri` receives the same authorization code — a few seconds later when the user creates a passkey, immediately when they decline: ```ts // Your callback handler is untouched. Whether the user created a passkey on // the way here is visible in the id token's `amr` claim on their NEXT login. const session = await auth.handleRedirectCallback() ``` ## Measuring adoption Every offer writes an audit row: `user.passkey_offer.shown` when the screen appears, `user.passkey_offer.accepted` when a passkey is created from it, `user.passkey_offer.dismissed` when the user declines (with the reason: `dismissed`, `unsupported` or `failed`). Each row carries the prompt number, so acceptance can be read per attempt under [Logs](logs.md) or pulled through the API with FaableQL: ``` type IN ("user.passkey_offer.shown", "user.passkey_offer.accepted") ``` On a user's detail page the **Passkey offer** row shows how many times they were asked and when they last declined. **Ask again** resets it — the answer both to "why does it keep asking me" and "I want to be asked again". Over the API: `POST /user/:id/passkey-prompt/reset`. ## Ask for the email first Under **Login Experience → Screen**, **Ask for the email first** turns the login into two steps: a single email field, then the methods that apply. On the second screen the email is shown, not asked again — the password form has only the password field, with a **Change** link back to the first step — and the emailed-code form comes pre-filled. It is the shape Stripe and Google use, and it is the natural place for the passkey suggestion to appear. Nothing is looked up between the two steps. The address is carried on the login, not resolved to a user, so the first screen cannot be used to find out which addresses have an account. ## Remember the last method **Remember the last method** moves the method a returning browser used last time to the top of the screen, with a _Last used_ tag. The hosted screen remembers it in a first-party cookie on your auth domain; whether to act on it is decided by this setting on the server, so turning it off is immediate and complete. ## Remember me on this device **Offer "Remember me on this device"** adds a checkbox to the password and emailed-code forms (a passkey login honours it too). Unticked, the session ends when the browser closes; ticked, it lasts the number of days you set (30 by default, up to 365). With the option off there is no box and every session lasts 30 days, as it always has. The checkbox only shortens or extends the session cookie on your auth domain. It is separate from **Remember this device** under two-step verification, which decides how long a browser is excused from a second factor. Over the API the setting is `login_methods.remember_me` (`off` or `optional`) and `remember_me_days`; the forms send `remember_me: true|false` when the box is shown. ## Per-client overrides Every setting on this page is a tenant default that a client can override for its own login screen — the method order, whether passkeys are offered, whether users are invited to create one. Open the client under **Clients → Login experience**; a client that sets nothing inherits what the account says, field by field. That is enough for the common split: the consumer app offers passwords and Google with a passkey invitation; the admin app offers only the corporate connection and [requires two-step verification](mfa.md#turning-it-on). For anything beyond flipping settings — different screens, conditions on who sees what — see [Login Flows](login-flows.md). ## Related - [Two-Step Verification](mfa.md) — authenticator apps, security keys and passkeys as a second factor. - [Passkeys in Next.js](guides/nextjs-passkeys.mdx) — the end-to-end guide. - [Clients](clients.md) — where per-client overrides live. --- ## APIs Source: https://faable.com/docs/auth/apis # APIs In OAuth 2.0 terminology, a **resource server** is the backend that consumes access tokens. In Faable Auth this is modeled as an **API**: a registered object that gives your backend its own audience identifier, defines the permissions clients can request, and controls how tokens are issued. You'll typically create one API per logical backend service. Each one is independently configurable: signing algorithm, token lifetime, whether consent is shown, and so on. ## Concept When a client requests a token for a specific resource server, it passes `audience=` to the `/oauth/token` endpoint. Faable issues an access token whose `aud` claim equals that identifier — your backend can then validate the token against its expected audience to reject tokens minted for other services. ``` client ──► /oauth/token { audience: "https://api.example.com", scope: "read:users" } │ └─► access_token { aud: "https://api.example.com", scope: "read:users", … } │ └─► your backend (verifies aud + signature, enforces scope) ``` ## Endpoints | Method | Path | Purpose | | -------- | ---------------- | -------------------------------------------------------------------- | | `POST` | `/apis` | Create an API. | | `GET` | `/apis` | List APIs (paginated, FaableQL-filterable). | | `GET` | `/apis/:apis_id` | Read one. | | `POST` | `/apis/:apis_id` | Update. The `identifier` field is **immutable** and must be omitted. | | `DELETE` | `/apis/:apis_id` | Delete. | All endpoints are scoped to the calling account. ## Creating an API ```http POST /apis Content-Type: application/json { "name": "Internal API", "identifier": "https://api.example.com", "signing_alg": "RS256", "token_lifetime": 86400, "token_dialect": "access_token_authz", "enforce_policies": true, "allow_offline_access": true, "skip_consent": false, "permissions": [ { "value": "read:users", "description": "Read user profiles" }, { "value": "write:users", "description": "Create or update users" } ] } ``` ### Fields | Field | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | Human-readable label shown in the dashboard and on consent screens. | | `slug` | URL-safe identifier, derived from `name` if omitted. Unique per account. | | `identifier` | The audience URL or URN used by clients (`audience=…`) and emitted as the `aud` claim. **Immutable** after creation. Must be unique per account. | | `description` | Optional free-text description. | | `permissions` | Array of `{ value, description? }`. The `value` is the scope string clients request (convention: `verb:resource`, e.g. `read:users`). The `description` is shown to users on the consent screen. | | `signing_alg` | JWT signing algorithm. Only `RS256` is supported today. | | `token_dialect` | `access_token` (standard) or `access_token_authz` (adds a `permissions` claim listing the granted scopes). | | `token_lifetime` | Access token TTL in seconds. Range: 60 (1 min) to 2_592_000 (30 days). Default: 86_400 (24 h). | | `enforce_policies` | When `true`, requested scopes are intersected with the API's `permissions` list — anything outside is dropped. When `false`, requested scopes are echoed verbatim. | | `allow_offline_access` | When `true`, clients can request `offline_access` and receive a `refresh_token` along with the access token. | | `skip_consent` | When `true`, first-party clients skip the consent prompt for this API. | ## Defining permissions Permissions live inline on the API and use the same string clients pass in the `scope` parameter: ```json "permissions": [ { "value": "read:projects", "description": "List and read projects" }, { "value": "write:projects", "description": "Create or modify projects" }, { "value": "admin:projects", "description": "Delete or transfer projects" } ] ``` When a client requests `scope=read:projects write:projects` against this API with `enforce_policies=true`, the issued token's `scope` claim contains exactly those two values. If `token_dialect=access_token_authz`, they're also reflected in a structured `permissions` claim: ```json { "aud": "https://api.example.com", "scope": "read:projects write:projects", "permissions": ["read:projects", "write:projects"] } ``` This is what your backend authorization middleware should check. ## Using your API from a client Once registered, request tokens for it via any standard OAuth flow: ```ts import { auth } from '@faable/auth-js' const token = await auth.getAccessToken({ audience: 'https://api.example.com', scope: 'read:projects' }) await fetch('https://api.example.com/projects', { headers: { Authorization: `Bearer ${token}` } }) ``` For server-to-server calls (no end user), see [Client Credentials](oauth-flows/client-credentials.mdx). ## Next steps - [Validate Access Tokens](validate-access-tokens.md) — verify these tokens in your backend (Express middleware included). - [Clients](clients.md) — register the application that will request tokens. - [Client Credentials flow](oauth-flows/client-credentials.mdx) — machine-to-machine token issuance for an API. - [Authorization Code flow](oauth-flows/authorization-code.mdx) — user-driven token issuance. --- ## RP-Initiated Logout Source: https://faable.com/docs/auth/oidc/logout # RP-Initiated Logout Faable Auth implements both [OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) and [OpenID Connect Front-Channel Logout 1.0](https://openid.net/specs/openid-connect-frontchannel-1_0.html). A single `GET /logout` call lets your application: 1. Terminate the user's session at the auth tenant. 2. Optionally notify every other Relying Party (RP) in that session so they can clear their local state in parallel. 3. Redirect the user back to a registered URL with optional state preserved. ## The endpoint ```http GET /logout?id_token_hint=&post_logout_redirect_uri=&state= ``` | Parameter | Description | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id_token_hint` | The ID Token previously issued to the user. RECOMMENDED (per spec §3): it lets Faable identify the client and session being ended. Decoded without signature verification — it's a hint, not an authorizer. | | `client_id` | OAuth client identifier. Inferred from `id_token_hint.aud` when omitted. | | `post_logout_redirect_uri` | Where to send the user after logout. **Must be pre-registered** in the client's `logout_urls` (exact match). This is what prevents open-redirect abuse. | | `state` | Opaque value echoed back as `?state=…` on the post-logout redirect. Use it for CSRF protection. | | `logout_hint` | Optional hint about the user being logged out (session id, email). Provider-specific. | | `ui_locales` | Space-separated preferred languages for any UI shown during logout. | | `returnTo` | Deprecated alias for `post_logout_redirect_uri`. Kept for backwards compatibility. | ## What happens ### Session termination 1. Faable enumerates RPs in the user's browser session that have a `frontchannel_logout_uri` registered. 2. The tenant session cookie is cleared. 3. Internal session tracking records are removed. ### Without front-channel logout If no RP in the session needs front-channel notification, Faable either: - returns a small JSON body `{ "status": "logout" }` (HTTP 200), or - issues a `302` redirect to `post_logout_redirect_uri` (with `?state=…` appended if provided). ### With front-channel logout If any RP needs notification, Faable renders an HTML page containing one sandboxed `