Facts reviewed: 2026-10-20

Owleye Analytics has two deliberately separate browser surfaces:

- **Visitor analytics** uses the public SDK and does not write cookies, local storage, or session storage.
- **The private console** uses secure authentication cookies because it contains account and app controls.

An authenticated console does not make the tracking SDK less cookie-free. They are different data paths with different jobs.

## Sign-in flow

Email sign-in is passwordless:

1. `POST /v1/auth/email/start` accepts an email address and sends a six-digit one-time code.
2. `POST /v1/auth/email/verify` consumes that code once.
3. Accounts with 2FA enabled complete `POST /v1/auth/2fa/verify` with a current authenticator code or an unused recovery code.

By default, public signup rejects disposable-email domains and any address whose local part contains `+` (for example, `name+project@gmail.com`); an operator can review and explicitly approve an individual address. The same eligibility check runs before both email-OTP and Google account creation, and uses one stable error message. This is an initial anti-abuse boundary, not proof that an accepted mailbox belongs to a trustworthy human; rate limits and provider-side abuse monitoring still matter.

Google sign-in starts at `GET /v1/auth/google/start`. The API uses a one-time OAuth state, PKCE S256, and a short-lived browser-binding cookie. Only a hash of that `HttpOnly`, `SameSite=Lax` binding is stored; the callback must present it before the state can be consumed, and the cookie is cleared on callback success or failure. This prevents a login initiated in one browser from being swapped into another. The API exchanges the authorization code server-side, then returns to the console. Configure the canonical callback as `/v1/auth/google/callback`; legacy route aliases remain for compatibility.

Local `OWLEYE_EMAIL_MODE=log` writes OTP codes to the API log for development. Use SMTP or Resend in any real deployment. Never use log mode on a public server.

## Configure Google sign-in for the hosted service

In [Google Auth Platform](https://console.cloud.google.com/auth/overview), select your project, configure Branding and an External audience, and create an OAuth client of type **Web application**. Register this exact authorized redirect URI:

```text
https://api.owleye.dev/v1/auth/google/callback
```

This is a server-side redirect flow; it does not require an authorized JavaScript origin. Use the website homepage `https://owleye.dev`, your published privacy and terms URLs, and authorized domain `owleye.dev`. Choose an eligible support email from Google's dropdown and set a monitored developer contact. Follow Google's [branding requirements](https://support.google.com/cloud/answer/15549049) to verify domain ownership and publish the app name/logo.

The code requests only `openid email profile`. Configure those scopes and set the audience's publishing status to **In production** when ready. Google documents an exception to Testing-mode restrictions for these basic sign-in scopes; adding other scopes changes that behavior. See [audience settings](https://support.google.com/cloud/answer/15549945) and [Web application OAuth setup](https://developers.google.com/identity/protocols/oauth2/web-server).

Set these in the private API's server environment and restart it:

```sh
OWLEYE_GOOGLE_CLIENT_ID=<web-client-id>
OWLEYE_GOOGLE_CLIENT_SECRET=<web-client-secret>
OWLEYE_GOOGLE_REDIRECT_URL=https://api.owleye.dev/v1/auth/google/callback
OWLEYE_APP_URL=https://app.owleye.dev
OWLEYE_ALLOWED_ORIGINS=https://app.owleye.dev
OWLEYE_COOKIE_SECURE=true
```

Keep the secret on the API server. Existing hash-salt and other hosted startup requirements still apply. For local development, use a separate OAuth client with `http://localhost:6174/v1/auth/google/callback` and a Console URL of `http://localhost:6173`. The configured callback must match Google's entry exactly.

The Console reads available sign-in methods from `GET /v1/auth/config`, which uses in-memory configuration without database queries; it does not depend on the strictly limited health routes. Test sign-in end to end after deployment, including an account with authenticator 2FA.

## Cookie-only sessions

Successful sign-in sets two `HttpOnly`, `SameSite=Lax` cookies. JavaScript cannot read either value, and Owleye Analytics never copies them into local or session storage.

- The access credential lasts 60 minutes by default.
- The opaque refresh credential lasts 30 days by default and is scoped to `/v1/auth`.
- `POST /v1/auth/refresh` rotates both credentials and slides the refresh expiry another 30 days.
- A replay of the immediately previous refresh credential revokes that session family.
- The previous access credential has a 30-second server-side grace window after rotation so an already-running API request can finish. Refresh credential replay detection remains strict.
- Logout and logout-all revoke SQLite session records and clear both cookies.

The session endpoint returns the actual access expiry. The console schedules rotation from that value with a TTL-adaptive safety margin while the page is visible, so custom access lifetimes from 5 minutes through 24 hours work without a hard-coded timer. Tabs coordinate rotation through Web Locks when available. Local storage shares only refresh-completion and access-expiry timestamps, never access or refresh credentials. Automatic rotation is skipped when Web Locks are unavailable. With coordinated refresh available, session bootstrap refreshes and retries once when an access credential has expired. Other feature requests are not automatically replayed after a `401`, which avoids duplicating writes; a future shared API client may add one bounded retry for explicitly safe reads. Refresh values are hashed in SQLite; TOTP seeds are encrypted at rest with a versioned key derived from `OWLEYE_HASH_SALT`. Existing plaintext TOTP rows remain readable and are upgraded after a successful 2FA login.

Production HTTPS deployments must use secure cookies. Owleye Analytics enables them automatically when `OWLEYE_APP_URL` starts with `https://`; set `OWLEYE_COOKIE_SECURE=true` explicitly when a proxy or unusual deployment obscures that scheme. Hosted production fails startup when secure cookies are disabled or email delivery uses log mode.

## First run

The first successful email or Google sign-in opens a skippable authenticator-app setup. Owleye Analytics supports standard TOTP authenticator apps here; no SMS or email MFA substitute. Completing or skipping the prompt is stored on the user in SQLite, so it does not reappear on every device.

Next, the owner creates an app with:

- a required app name;
- an optional comma-separated list of allowed domains.

With no domains configured, event tracking and rule delivery accept any HTTP or HTTPS browser origin. With one or more domains configured, an origin must match one of them; matching is by hostname, so all paths on that domain are covered. Additional domains can use explicit wildcards such as `*.example.com`. For restricted apps, loopback origins (`localhost`, `*.localhost`, `127.0.0.0/8`, and `::1`) require the server's local-development opt-in. Specified domains are normalized and inserted with the app in one transaction. The console then reveals the API-created tracking ID, public key, and SDK snippet.

## Two-factor recovery codes

Enabling 2FA returns ten recovery codes. The Console shows them once, with copy and download controls, and asks you to confirm you saved them. Keep them in a password manager or another safe place. These are single-use codes, not a seed phrase.

If you lose your authenticator, complete your usual email or Google sign-in, choose **Use a recovery code**, and enter one unused code. Recovery codes replace the authenticator step; they do not replace your primary sign-in method.

Existing 2FA users can open **Profile → Two-factor authentication → Generate new recovery codes**. This requires a recent second-factor confirmation. Every replacement invalidates the entire previous set and requires a fresh confirmation for the next replacement. A successful 2FA sign-in qualifies for ten minutes, including when you sign in using your last recovery code.

`GET /v1/auth/2fa/recovery-codes` returns only the remaining count. Authenticated `POST` to that route generates a replacement set. The API stores only account-bound hashes in SQLite, sends recovery-code responses with `Cache-Control: no-store`, and atomically consumes each code with the successful action. Disabling 2FA invalidates all recovery codes.

## Required configuration

```sh
OWLEYE_APP_URL=https://app.example.com
OWLEYE_ALLOWED_ORIGINS=https://app.example.com
OWLEYE_HASH_SALT=<at-least-32-random-bytes>

OWLEYE_SESSION_COOKIE=owleye_session
OWLEYE_REFRESH_COOKIE=owleye_refresh
OWLEYE_OAUTH_BINDING_COOKIE=owleye_oauth_binding
OWLEYE_ACCESS_TOKEN_MINUTES=60
OWLEYE_REFRESH_TOKEN_DAYS=30
OWLEYE_COOKIE_SECURE=true

OWLEYE_EMAIL_MODE=smtp
OWLEYE_SMTP_HOST=smtp.example.com
OWLEYE_SMTP_PORT=587
OWLEYE_SMTP_USERNAME=<smtp-user>
OWLEYE_SMTP_PASSWORD=<smtp-password>
OWLEYE_SMTP_FROM=analytics@example.com

OWLEYE_GOOGLE_CLIENT_ID=<google-client-id>
OWLEYE_GOOGLE_CLIENT_SECRET=<google-client-secret>
OWLEYE_GOOGLE_REDIRECT_URL=https://api.example.com/v1/auth/google/callback
```

Keep `OWLEYE_HASH_SALT` stable and secret. Changing it invalidates access credentials and recovery codes, and prevents existing encrypted TOTP seeds from being opened.

## Account data lifecycle

`GET /v1/account/export` downloads the signed-in user's account and control-plane data as JSON. `DELETE /v1/account` deletes the live canonical sign-in account after the user transfers or deletes every app and organization they own. Both routes reject read-only preview workspaces; deletion also clears the session cookies. Audit evidence, external recipients, logs, and backups need separate reviewed retention, while analytics facts remain under the app-owner lifecycle so an account cannot silently orphan or erase another member's app.

## Email challenge binding

`POST /v1/auth/email/start` returns `{ "status": "sent", "challenge_id": "..." }`. Keep that random challenge ID in the initiating browser flow's memory and send it with `email` and `code` to `POST /v1/auth/email/verify`. An address alone cannot select and exhaust someone else's verification attempts. The console handles this automatically.

A new code request does not invalidate an existing flow. Successful verification consumes that challenge and invalidates other outstanding login codes for the address. Expiry, atomic attempt limits, privacy-safe rate limits, and second-factor requirements still apply. Older API clients must be updated to send `challenge_id`; there is no unbound compatibility fallback.