SSO & OAuth Authentication
Configure Google, GitHub, HuggingFace and generic OIDC login in Potato. Restrict access by email domain or GitHub organization, and run SSO alongside local password login.
New in v2.3.0
By default, Potato uses a simple username-based login where annotators type any username to sign in. That works for local development and small projects, but production deployments need proper authentication to prevent unauthorized access, confirm annotator identity, and meet institutional requirements.
SSO is configured in one place. Set authentication.method to oauth, then list one or more providers under authentication.providers. Each provider gets its own button on the login page.
Potato has built-in endpoint metadata for three providers: google, github and huggingface. Any other provider name is treated as generic OIDC and needs a discovery_url.
Quick start
A minimal Google setup:
authentication:
method: "oauth"
providers:
google:
client_id: ${GOOGLE_CLIENT_ID}
client_secret: ${GOOGLE_CLIENT_SECRET}
user_identity_field: "email"
secret_key: ${POTATO_SECRET_KEY}Set the secrets in the environment rather than in the file:
export GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
export GOOGLE_CLIENT_SECRET="your-client-secret"
export POTATO_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"secret_key is a top-level key, not part of the authentication block. Set it, or set POTATO_SECRET_KEY. Potato generates a random key when neither is present, which means every restart invalidates every session, and it logs a warning when OAuth is configured without one.
Potato serves two OAuth routes: /auth/login/<provider> starts the flow and /auth/callback/<provider> receives the response. The callback is the URL you register with the provider, so for Google it is https://your-domain.com/auth/callback/google. There is no redirect_uri config key; the route is derived from the provider name.
Create OAuth 2.0 credentials of type "Web application" in the Google Cloud Console, and add https://your-domain.com/auth/callback/google as an authorized redirect URI. For local development, http://localhost:8000/auth/callback/google is accepted.
authentication:
method: "oauth"
providers:
google:
client_id: ${GOOGLE_CLIENT_ID}
client_secret: ${GOOGLE_CLIENT_SECRET}
allowed_domain: "umich.edu"allowed_domain takes a single domain string. Only accounts whose email is in that domain can sign in.
GitHub
Register an OAuth app under GitHub Settings > Developer settings > OAuth Apps, with the callback URL set to https://your-domain.com/auth/callback/github.
authentication:
method: "oauth"
providers:
github:
client_id: ${GITHUB_CLIENT_ID}
client_secret: ${GITHUB_CLIENT_SECRET}
allowed_org: "my-research-lab"allowed_org takes a single organization name. Potato requests the read:org scope automatically when it is set, so you do not need to list scopes yourself. The annotator must have public membership in the organization for the check to see it.
HuggingFace
Create an OAuth application at huggingface.co/settings/applications.
authentication:
method: "oauth"
providers:
huggingface:
client_id: ${HF_CLIENT_ID}
client_secret: ${HF_CLIENT_SECRET}
user_identity_field: "username"The HuggingFace provider connects to HuggingFace's OIDC discovery endpoint and requests openid profile email by default.
Generic OIDC
Any provider name other than google, github and huggingface is treated as generic OIDC and requires discovery_url. Give it a display_name so the login button is readable.
authentication:
method: "oauth"
providers:
oidc:
display_name: "University SSO"
discovery_url: "https://sso.university.edu/.well-known/openid-configuration"
client_id: ${OIDC_CLIENT_ID}
client_secret: ${OIDC_CLIENT_SECRET}
scopes:
- "openid"
- "email"
- "profile"The callback URL to register with the provider follows the provider key, so a block named oidc uses https://your-domain.com/auth/callback/oidc.
Discovery URLs for common providers:
| Provider | Discovery URL |
|---|---|
| Okta | https://your-org.okta.com/.well-known/openid-configuration |
| Azure AD | https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration |
| Auth0 | https://your-domain.auth0.com/.well-known/openid-configuration |
| Keycloak | https://keycloak.example.com/realms/{realm}/.well-known/openid-configuration |
scopes defaults to ["openid", "email", "profile"].
Multiple providers
providers holds as many entries as you need, and each one renders its own login button.
authentication:
method: "oauth"
providers:
google:
client_id: ${GOOGLE_CLIENT_ID}
client_secret: ${GOOGLE_CLIENT_SECRET}
github:
client_id: ${GITHUB_CLIENT_ID}
client_secret: ${GITHUB_CLIENT_SECRET}
oidc:
display_name: "University SSO"
discovery_url: "https://sso.university.edu/.well-known/openid-configuration"
client_id: ${OIDC_CLIENT_ID}
client_secret: ${OIDC_CLIENT_SECRET}With user_identity_field: "email", someone who signs in through Google and later through the university provider with the same address is the same Potato user, and lands back on the same assignments.
Identity and auto-registration
user_identity_field decides which claim from the provider becomes the Potato username. It accepts email, username, sub or name, and defaults to email. The value matters because annotator output directories are named after the username, so changing it mid-study separates an annotator from their earlier work.
auto_register controls whether a successful first login creates the Potato account. It defaults to true.
authentication:
method: "oauth"
providers:
google:
client_id: ${GOOGLE_CLIENT_ID}
client_secret: ${GOOGLE_CLIENT_SECRET}
allowed_domain: "umich.edu"
user_identity_field: "email"
auto_register: trueAccess control runs through the provider rather than through a list of addresses in the config. To limit who can sign in, use allowed_domain for Google, allowed_org for GitHub, or the group and app-assignment controls in your OIDC provider.
Mixed mode
allow_local_login adds the username and password form below the SSO buttons. It defaults to false, so SSO replaces local login unless you turn it on.
require_password: true
user_config:
allow_all_users: true
authentication:
method: "oauth"
providers:
google:
client_id: ${GOOGLE_CLIENT_ID}
client_secret: ${GOOGLE_CLIENT_SECRET}
allow_local_login: trueA migration runs both for a while: move annotators over, then drop allow_local_login once everyone has signed in through the provider. Mixed mode also covers studies that combine institutional annotators with external ones who have no account at your institution.
Local accounts in mixed mode use the same salted PBKDF2 hashing as any other password account. See Password Management for the reset flows.
Sessions
Session lifetime is a top-level key, in days:
session_lifetime_days: 7A stable secret_key is what makes sessions survive a restart.
Admin access
Admin endpoints authenticate with an API key rather than through the OAuth block:
admin_api_key: ${POTATO_ADMIN_API_KEY}Potato reads admin_api_key from the config first, then the POTATO_ADMIN_API_KEY environment variable. If neither is set it generates a key and writes it to admin_api_key.txt in the task directory. Callers present it as the X-API-Key header.
HTTPS
OAuth providers require HTTPS redirect URIs in production. localhost is exempt, so development over HTTP works without a certificate.
Behind a reverse proxy, forward the original scheme or the callback URL Potato builds will use http and the provider will reject it:
location / {
proxy_pass http://localhost:8000;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
}Configuration reference
| Key | Type | Default | Description |
|---|---|---|---|
authentication.method | string | in_memory | in_memory, database, clerk or oauth |
authentication.providers | dict | required for oauth | One entry per provider, keyed by provider name |
authentication.user_identity_field | string | email | email, username, sub or name |
authentication.allow_local_login | bool | false | Show the password form alongside SSO buttons |
authentication.auto_register | bool | true | Create the Potato account on first login |
secret_key | string | random | Top-level. Stable value required for persistent sessions |
session_lifetime_days | int | 7 | Top-level session duration |
admin_api_key | string | generated | Top-level key for admin endpoints |
Per-provider keys:
| Key | Type | Required | Description |
|---|---|---|---|
client_id | string | yes | From the provider |
client_secret | string | yes | From the provider |
discovery_url | string | for generic OIDC | OIDC discovery endpoint |
display_name | string | no | Login button text |
allowed_domain | string | no | Restrict to one email domain |
allowed_org | string | no | Restrict to one GitHub organization |
scopes | list | no | Defaults to ["openid", "email", "profile"] |
Full example
annotation_task_name: "Annotation Project"
task_dir: "."
authentication:
method: "oauth"
providers:
google:
client_id: ${GOOGLE_CLIENT_ID}
client_secret: ${GOOGLE_CLIENT_SECRET}
allowed_domain: "umich.edu"
user_identity_field: "email"
auto_register: true
secret_key: ${POTATO_SECRET_KEY}
session_lifetime_days: 7
admin_api_key: ${POTATO_ADMIN_API_KEY}
data_files:
- "data/instances.jsonl"
item_properties:
id_key: id
text_key: text
annotation_schemes:
- annotation_type: radio
name: sentiment
description: "What is the sentiment of this text?"
labels: [Positive, Neutral, Negative]
output_annotation_dir: "output/"
export_annotation_format: "jsonl"Troubleshooting
The provider rejects the redirect URI. The URI registered with the provider must match /auth/callback/<provider> exactly. The scheme and any trailing path count as part of the match, so a proxy that does not forward X-Forwarded-Proto produces an http callback that the provider rejects.
Everyone is logged out after a restart. secret_key is unset, so Potato generated a new one at boot.
A GitHub organization restriction rejects a member. Their organization membership is private. Either they make it public or you drop allowed_org and control access another way.
Validation fails with "providers is required". method: "oauth" needs at least one entry under providers. The method value is oauth, not the name of a provider.
Further Reading
- Password Management -- local accounts, hashing and reset flows
- Passwordless Login -- username-only access for open tasks
- MTurk Integration -- crowdsourcing platform authentication
- Admin Dashboard -- managing users and permissions
For implementation details, see the source documentation.