Add social sign-in
Let users sign in with Google or any OpenID Connect provider, set up with the zitadel CLI.
A social sign-in provider, such as "Sign in with Google", is one more sign-in method on a user schema. auth-method sso enable writes the connection, turns it on in the schema and login flow, and stores the provider's credentials on the project. deploy then publishes it.
Register the app with the provider
Create an OAuth client (for Google: a "Web application" client in the Google Cloud console) and keep its client id and client secret. The provider asks for a redirect URI. sso enable prints the right one as callback_uri; locally, for Google, it is:
http://localhost:3000/__nextgen/idp/google/callbackEach connection has its own callback URI, on your app's origin, ending in the connection's slug. Register it with the provider exactly as printed. A URI from another connection doesn't work.
Enable Google
npx @zitadel/cli@latest auth-method sso enable --provider googleThe command prompts for the client id and the client secret. In a script or an agent, pass the client id as a flag and pipe the secret in on stdin; the secret never goes in a flag:
npx @zitadel/cli@latest auth-method sso enable --provider google \
--client-id 1234-abc.apps.googleusercontent.com \
--non-interactive --json < secret.txtPass --schema <name> when the project has more than one user schema. The JSON result names what it did:
{
"provider": "google",
"schema": "default-human-user",
"connection": { "action": "create", "slug": "google", "file": ".zitadel/idps/google.json" },
"callback_uri": "http://localhost:3000/__nextgen/idp/google/callback",
"changed": [".zitadel/schemas/default-human-user.json", ".zitadel/flows/default-login.json"],
"client_id": { "variable": "GOOGLE_CLIENT_ID", "published": "stored" },
"secret": { "variable": "GOOGLE_CLIENT_SECRET", "published": "stored" },
"next_commands": ["npx @zitadel/cli@latest deploy --dry-run", "npx @zitadel/cli@latest deploy"]
}Then publish the change:
npx @zitadel/cli@latest deploy --dry-run
npx @zitadel/cli@latest deployReload the app: the sign-in, registration and password steps show a "Continue with Google" button.
Any other OpenID Connect provider
--provider custom connects any provider that publishes OIDC discovery (<issuer>/.well-known/openid-configuration):
npx @zitadel/cli@latest auth-method sso enable --provider custom \
--slug acme-sso --display-name 'Acme SSO' \
--issuer https://sso.acme.example \
--scopes 'openid profile email groups' \
--client-id abc123 --non-interactive < secret.txt--slugnames the connection. Schemas and flows refer to it by this name, and it is part of the callback URI (/__nextgen/idp/acme-sso/callback).--display-nameis the text on the sign-in button.--issueris the provider's issuer URL; the server finds its endpoints by discovery.--scopesis optional, separated by spaces or commas, and must includeopenid. The default isopenid profile email.
The credentials are stored as <SLUG>_CLIENT_ID and <SLUG>_CLIENT_SECRET, for example ACME_SSO_CLIENT_ID.
During setup
setup takes the same provider while it scaffolds the app, so a new app starts with social sign-in. The OAuth client must already exist; pipe its secret in on stdin:
npx @zitadel/cli@latest setup --server local --framework next \
--sso google --sso-client-id 1234-abc.apps.googleusercontent.com \
--non-interactive --json < secret.txtFor a custom provider, use --sso custom with --sso-slug, --sso-display-name, --sso-issuer and optionally --sso-scopes. Setup reports each connection under data.sso[], with its callback_uri to register.
What the command writes
.zitadel/idps/<slug>.json, the connection: protocol, issuer, scopes, claim mapping, and the credentials as references,${{ GOOGLE_CLIENT_ID }}and${{ GOOGLE_CLIENT_SECRET }}. The file holds no secret, so commit it.- The user schema,
.zitadel/schemas/<schema>.json: the slug is added tox-auth-methods.sso.providers, andx-auth-methods.sso.enabledis set totrue. - The login flow,
.zitadel/flows/<flow>.json: the slug is added tosso_providerson the steps that offer sign-in, and two steps are added.register-ssocreates the user on a first sign-in with the provider.sso-conflicthandles a provider account whose email already belongs to a user (see Limits). - Two project variables,
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET(the secret encrypted). They live on the project, not in.env.local: the server exchanges the code with the provider, so the app never needs the secret.
The variables are the one thing sso enable publishes before deploy. When a credential did not reach the project, for example because the secret was not piped in or the server refused it, published is deferred or failed and next_commands starts with the variables set command that stores it:
npx @zitadel/cli@latest variables set GOOGLE_CLIENT_SECRET --project-level --secret < secret.txtTo remove a provider, run auth-method sso disable --provider google; a custom one is named by its slug, as in --provider acme-sso. Then deploy --dry-run and deploy.
Fields fixed after the first apply
Once a connection has been applied, these fields identify it and the users who signed in through it, and can't change:
slug: a file with another slug is another connectionprotocoloidc.issuersubject_claim
deploy refuses an edit to one of them:
Error E_VALIDATION: .zitadel/idps/google.json: subject_claim of the connection "google" cannot be editedRestore the stored value, or add a new connection file with its own slug. Everything else in the file, such as display_name or scopes, can be edited and applied.
Test against a local OIDC provider
A provider running on your machine, such as a Keycloak or Dex container on localhost, works with the server zitadel start runs. A server refuses outgoing requests to loopback addresses unless NEXTGEN_HTTPCLIENT_ALLOW_LIST allows them, and start sets it to localhost,127.0.0.0/8,::1/128 unless .env.local, .env or your shell already sets it. data.runtime.env.injected in start --json lists the variable.
Two cases still can't reach the provider, and sso enable warns about both:
- The Docker runtime. Inside the container,
localhostis the container itself. Start the server withzitadel start --runtime binaryinstead. - Any other server, or a local one with
NEXTGEN_HTTPCLIENT_ALLOW_LIST=set empty, which turns the default off.
setup --sso custom doesn't know yet which server will run, so it warns about any localhost issuer.
When the server can't reach the provider, the widget only says "This sign-in provider is not available right now", and zitadel logs --level warn shows a sso provider unavailable warning with idp.discovery_failed and address is denied by 'localhost'. After changing the variable in .env.local, stop the server and start it again: start alone keeps a running server as it is.
npx @zitadel/cli@latest stop
npx @zitadel/cli@latest startOnly allow loopback on your machine. A deployed server must not reach its own network on a URL that a project can configure.
Limits
- No account linking yet. A provider account is never linked to an existing user. When someone who registered with a password signs in with Google using the same email, the flow lands on the
sso-conflictstep, which asks them to sign in with their password or passkey instead. That happens on every Google sign-in, because the two stay separate. A user who started with Google signs in with Google. - OpenID Connect only. The CLI knows Google by name; any other provider needs
--provider customand an issuer with OIDC discovery. A plain OAuth 2.0 provider, such as GitHub, can't be connected yet.
Was this helpful?