Skip to content

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:

yaml
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:

bash
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.

Google

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.

yaml
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.

yaml
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.

yaml
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.

yaml
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:

ProviderDiscovery URL
Oktahttps://your-org.okta.com/.well-known/openid-configuration
Azure ADhttps://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration
Auth0https://your-domain.auth0.com/.well-known/openid-configuration
Keycloakhttps://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.

yaml
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.

yaml
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

Access 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.

yaml
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: true

A 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:

yaml
session_lifetime_days: 7

A 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:

yaml
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:

nginx
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

KeyTypeDefaultDescription
authentication.methodstringin_memoryin_memory, database, clerk or oauth
authentication.providersdictrequired for oauthOne entry per provider, keyed by provider name
authentication.user_identity_fieldstringemailemail, username, sub or name
authentication.allow_local_loginboolfalseShow the password form alongside SSO buttons
authentication.auto_registerbooltrueCreate the Potato account on first login
secret_keystringrandomTop-level. Stable value required for persistent sessions
session_lifetime_daysint7Top-level session duration
admin_api_keystringgeneratedTop-level key for admin endpoints

Per-provider keys:

KeyTypeRequiredDescription
client_idstringyesFrom the provider
client_secretstringyesFrom the provider
discovery_urlstringfor generic OIDCOIDC discovery endpoint
display_namestringnoLogin button text
allowed_domainstringnoRestrict to one email domain
allowed_orgstringnoRestrict to one GitHub organization
scopeslistnoDefaults to ["openid", "email", "profile"]

Full example

yaml
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

For implementation details, see the source documentation.