Skip to content

Password Management

Configure PBKDF2-SHA256 password hashing, admin CLI and API resets, self-service token reset links, and SQLite or PostgreSQL credential storage in Potato.

New in v2.4.0

Potato's authentication system uses PBKDF2-SHA256 with 100,000 iterations and per-user salts — the same approach recommended by NIST for secure password storage. This page covers how passwords are stored, how to reset them, and how to persist credentials across server restarts.

It applies to the in_memory and database methods. Under OAuth the identity provider owns the password, so none of this applies except to the local accounts in mixed mode.

Security Implementation

Passwords are stored in salt$hash format:

  • 32-character hex salt, unique per user
  • 64-character hex digest, derived by PBKDF2-HMAC-SHA256 over the password with that salt and 100,000 iterations
  • Constant-time comparison via hmac.compare_digest to prevent timing attacks

A single pass of SHA-256 over a salted password is fast enough to brute-force at scale. The 100,000 iterations are what make each guess expensive.

Existing plaintext passwords in user_config.json files are automatically re-hashed with unique salts when Potato loads them — no manual migration needed.

Default Configuration

By default, Potato uses in-memory authentication. require_password is a top-level key rather than part of the authentication block:

yaml
require_password: true
 
authentication:
  method: in_memory
 
user_config:
  users:
    - username: "annotator1"
      password: "initial-password"   # will be hashed on first load
    - username: "annotator2"
      password: "initial-password"

Persistent Credentials

Accounts created through the sign-up form are written to a JSONL file and read back at the next boot. Under in_memory that file is user_config.json beside the output directory unless you name another one.

File-Based Persistence

yaml
authentication:
  method: in_memory
  user_config_path: /shared/path/to/user_config.jsonl

Name a path when several server instances share one roster, or when you want the roster somewhere other than beside the annotations. New registrations and password changes are written as they happen.

Keep the roster with the annotation output. An annotator's directory is named after their username, so a roster that has gone missing while the output directory survives is a study where the next person to type that username would inherit someone else's work. Potato refuses the registration instead, which means the annotator cannot get back in until you restore the file.

Before v2.8.3, in_memory wrote nothing unless user_config_path was set explicitly, so accounts did not survive a restart. If you ran a study on an earlier build, those accounts are gone; the annotations are not.

Database Backend

SQLite (no additional dependencies):

yaml
authentication:
  method: database
  database_url: "sqlite:///auth/users.db"

PostgreSQL (requires psycopg2-binary):

yaml
authentication:
  method: database
  database_url: "postgresql://user:password@localhost:5432/potato_auth"

Potato creates the users table on first startup and runs SQLite in WAL mode for better concurrent reads. database_url must start with sqlite:/// or postgresql://. You can also set POTATO_DB_CONNECTION in the environment, which database_url overrides when both are present.

Note: method: database and user_config_path are mutually exclusive — choose one persistence strategy. Potato raises an error if both are configured.

Resetting Passwords

Admin CLI

Reset a password from the command line:

bash
# Prompts for username and password
potato reset-password config.yaml
 
# Prompts for the password only
potato reset-password config.yaml --username annotator1

Admin API

Reset programmatically with the admin API key:

bash
curl -X POST http://localhost:8000/admin/reset_password \
  -H "X-API-Key: $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"username": "annotator1", "new_password": "new-secure-password"}'

The key comes from the top-level admin_api_key setting, the POTATO_ADMIN_API_KEY environment variable, or a generated admin_api_key.txt in the task directory, in that order.

Self-Service Token Reset

Annotators who forget a password can reset it themselves. They visit /forgot-password and enter their username, and Potato generates a single-use token valid for 24 hours. The reset link is shown on screen for an admin to pass on; Potato sends no email. The annotator opens /reset/<token> and sets a new password, which consumes the token.

The flow needs no configuration. When require_password: true is set, a "Forgot Password?" link appears on the login page.

Admins can also mint a token directly:

bash
curl -X POST http://localhost:8000/admin/create_reset_token \
  -H "X-API-Key: $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"username": "annotator1"}'
 
# {"reset_link": "http://localhost:8000/reset/abc123...",
#  "token": "abc123...", "expires_in_hours": 24}

Pass "ttl_hours": 48 in the request body for a different expiry.

Passwordless Mode

For classroom demos, quick studies, or tasks using external authentication (Prolific, SONA), you can disable passwords entirely:

yaml
require_password: false
 
authentication:
  method: in_memory

Annotators enter any username to log in — no password prompt appears. Not recommended for sensitive data or tasks requiring verified identity.

See Passwordless Login for details.

Complete Reference

yaml
# Top-level: require a password at login (default: true)
require_password: true
 
# Top-level: key for the admin endpoints
admin_api_key: ${POTATO_ADMIN_API_KEY}
 
authentication:
  # in_memory (default) | database | clerk | oauth
  method: in_memory
 
  # in_memory only, mutually exclusive with method: database
  user_config_path: users.jsonl
 
  # database only; sqlite:/// or postgresql://
  # database_url: "sqlite:///auth.db"
 
user_config:
  users:
    - username: "researcher"
      password: "secure-passphrase"
    - username: "annotator1"
      password: "initial-pass"
EndpointMethodAuthDescription
/forgot-passwordGET, POSTnoneRequest a reset token
/reset/<token>GET, POSTnoneSet a new password
/admin/reset_passwordPOSTAPI keyAdmin password reset
/admin/create_reset_tokenPOSTAPI keyGenerate a reset token

Further Reading

For implementation details, see the source documentation.