# Logins, viewer accounts and share links

Each login to the viewer has a role and a set of granted chats. TLS, proxy setup and the routes that need no login are in [Exposing the viewer safely](<https://geiserx.github.io/Telegram-Archive/viewer/exposing/index.md>).

## The viewer starts closed

The viewer serves no archive data until you choose how people log in. You have three options:

- `VIEWER_USERNAME` plus `VIEWER_PASSWORD` for a master login.
- `AUTH_PROXY_HEADER` for identity set by a trusted reverse proxy.
- `ALLOW_ANONYMOUS_VIEWER=true` for an open, read-only viewer.

Single sign-on with OpenID Connect is an extra way to sign in, not a fourth option: it only works when `VIEWER_USERNAME` and `VIEWER_PASSWORD` are set. See [Single sign-on with OpenID Connect](<#single-sign-on-with-openid-connect>).

With none of them set, the container is up and healthy, the login page shows, and sign-in fails. Every data route answers HTTP 503 `Viewer authentication is not configured`.

## Roles

| Role | Who gets it | What it can do |
| --- | --- | --- |
| master | The `VIEWER_USERNAME` login, a proxy user listed in `AUTH_PROXY_ADMIN_USERS`, or an SSO identity listed in `OIDC_ADMIN_USERS` or mapped to `VIEWER_USERNAME` in `OIDC_USER_MAP` | Everything, including admin-only features |
| viewer | A viewer account created by the master login, a proxy user, or an SSO identity that is not an admin | Only the Telegram accounts and chats granted to it |
| token | A session opened with a share link | Only the chats of that token |
| viewer, user name `anonymous` | Every visitor when `ALLOW_ANONYMOUS_VIEWER=true` | Read-only access to every chat in `DISPLAY_CHAT_IDS`. See [Anonymous mode](<#anonymous-mode>). |

The master login also gets **Admin settings**, with viewer accounts, share tokens, the activity log and sessions, the **Archive status** page of the main menu, and file paths in the info panel. The main menu names each login's role: Owner, Viewer or Shared link. When `MEDIA_OPEN_CMD` or `MEDIA_OPEN_PATH_CMD` is set, the master login also gets the media **Open** buttons.

## The master login

Set both variables in the viewer's environment:

```
services:
  telegram-viewer:
    environment:
      VIEWER_USERNAME: admin
      VIEWER_PASSWORD: change-me-to-a-long-password
```

Password login is on only when both values are non-empty. Leading and trailing spaces are stripped from both. At login the viewer checks viewer accounts first, then the master login. The master comparison takes the same time however many characters match, so timing does not reveal how close a guess was.

A request with the header `X-Viewer-Only: true` cannot log in as master or reach master routes. Viewer accounts can still log in with that header present.

Viewer passwords and share tokens are stored as PBKDF2-HMAC-SHA256 hashes with 600,000 rounds. The master password is never stored; it lives only in the environment.

## Sessions

A successful login sets the cookie `viewer_auth`, which is `HttpOnly` and `SameSite=Lax`.

- A session lasts `AUTH_SESSION_DAYS` days, default 30, counted from login, not from last use. The value must be a whole number: anything else stops the viewer at start.
- A running viewer keeps at most 10 sessions per user in memory. An 11th login ends the oldest one it knows about. At start it loads every stored session, but a session another viewer opens later is counted only once a request uses it here.
- Sessions live in the database and survive a viewer restart. A sweep every 900 seconds removes expired ones and closes their live connections.
- A viewer checks each session it holds in memory against the database again once the last check is 60 seconds old. A session ended on another viewer on the same database stops working here within 60 seconds. See [A second viewer](<#a-second-viewer>).
- Logging out ends that session and deletes every push subscription of that user. Other browsers of the same user subscribe again on their next load.

> [!WARNING]
>
> **Changing the master password does not log anyone out**
>
> The viewer does not check existing sessions of the master login against `VIEWER_PASSWORD` again. After a password change they stay valid until `AUTH_SESSION_DAYS` runs out, or until you [end every session](<#end-every-session>). Renaming `VIEWER_USERNAME` does end the old name's master sessions, on their next request.

Logins and share-token logins share one rate limit: 15 attempts per client IP in 300 seconds, then HTTP 429. Behind a reverse proxy every client can look like the same IP, so one client can use up the limit for all. [Exposing the viewer safely](<https://geiserx.github.io/Telegram-Archive/viewer/exposing/index.md>) shows how to fix this.

## Reset a password

### The master password

Set a new `VIEWER_PASSWORD` in `.env` and recreate the viewer:

```
docker compose up -d telegram-viewer
```

Sessions opened with the old password stay valid. See the warning under [Sessions](<#sessions>). To log them out, sign in with the new password and [end every session](<#end-every-session>). **End all but this one** keeps the session you just opened.

### A viewer account

Open **Admin settings**, **Viewers**, click the pencil beside the viewer account and type a new password of at least 8 characters. Saving ends that account's sessions. See [Viewer accounts](<#viewer-accounts>).

### A share token

The token is shown once and only its hash is stored, so a lost link cannot be recovered. Revoke or delete the token and create a new one.

### Proxy identity

The password belongs to the reverse proxy or the identity provider in front of it. The viewer stores none.

## End every session

The master can log out every browser at once, for example after a master password change. This ends the master's sessions, every viewer account's sessions and every share-token session. Accounts and share tokens stay as they are, so people log in again with their current password or link.

Open the main menu (the three lines at the top of the sidebar), **Admin settings**, then the **Sessions** tab. It has two rows, and each asks you to confirm:

- **End every session** logs out everyone, you included. The page returns to the login.
- **End all but this one** keeps the browser you click it in and logs out everything else, your other browsers included.

Both close the live connections of the ended sessions and delete the push subscriptions of their users. Those users subscribe again after their next login. The audit log records the action as `sessions_ended_all`, or `sessions_ended_all:kept_current` when your session was kept. A second viewer on the same database drops the ended sessions within 60 seconds. See [A second viewer](<#a-second-viewer>).

The same action is the route `POST /api/admin/sessions/end-all`. Only the master may call it: the master login, or a proxy identity listed in `AUTH_PROXY_ADMIN_USERS`. Viewer accounts and share-token sessions get HTTP 403, and so does a request with `X-Viewer-Only: true`. Add `?keep_current=true`, or send the JSON body `{"keep_current": true}`, to keep the session of the cookie you call it with. Without either, your own session ends too:

```
curl -X POST -b "viewer_auth=<your session cookie>" \
  "https://archive.example.com/api/admin/sessions/end-all?keep_current=true"
```

The answer names how many sessions ended and whether yours was one of them:

```
{"success": true, "ended": 4, "current_session_ended": false}
```

When `current_session_ended` is `true`, the answer also clears the cookie. A caller signed in through proxy identity has no session of its own, so the action ends nothing of theirs and the next request signs them in again through the proxy.

## Viewer accounts

The master login manages accounts in the viewer itself. Open the main menu, **Admin settings**, then the **Viewers** tab. Each account is a row with its name and a line such as `3 chats · Personal · downloads off · inactive`, a pencil to edit it and a trash to delete it. Deleting asks first. **Add viewer** opens the form in place:

| Field | Meaning |
| --- | --- |
| Username | At least 3 characters. It cannot match the master username, in any letter case. |
| Password | At least 8 characters. When editing, leave it empty to keep the current one. |
| Allowed chats | The chats this user may open, as a list with a filter field on top and a count of the chats you ticked. |
| Allowed accounts | **All accounts**, or only the Telegram accounts you tick. Ticking none means the user sees no chats. |
| Active | Switch it off to block the account without deleting it. |
| Allow downloads | Switch it off to make this a [no-download login](<#no-download-logins>). |

![Admin settings with the viewer accounts](<https://geiserx.github.io/Telegram-Archive/images/screenshots/admin-settings.png>)

Saving a change to a viewer account, or deleting it, ends all of its sessions, closes its live connections and deletes its push subscriptions. The user has to log in again. A second viewer on the same database drops those sessions within 60 seconds. See [A second viewer](<#a-second-viewer>).

> [!WARNING]
>
> **No chats ticked means all chats**
>
> The form sends an empty **Allowed chats** list as "all chats", and says so under the list. Viewer accounts created through proxy identity start with no chats at all. If you edit one of them and tick nothing, you widen it to every chat. Always tick the chats you mean to grant.

## Share links

A share link gives someone access to a fixed set of chats without an account. In **Admin settings**, open **Share tokens** and click **Add share token**:

| Field | Meaning |
| --- | --- |
| Label | Optional. It names the session and the audit entries. |
| Expiry date and time | Optional. You enter it in your browser's local time and it is stored in UTC. |
| Allowed chats | Required. A token must name at least one chat. |
| Allow downloads | On by default. Switch it off to make this a [no-download login](<#no-download-logins>). |

Press **Create**. The viewer shows a 64-character hex token and a ready link, each with a copy button. They are shown only once. The database keeps only a salted hash. The link has this form:

```
https://archive.example.com/#token=<token>
```

When the link opens, the page signs in with the token and removes it from the address bar. Browsers never send the part after `#` to the server, so the token stays out of access logs.

You can also paste the token into the **Share Token** mode of the login page. A hand-made `?token=` link works too, but it reaches server access logs.

The session is named `token:<label>`, and it sees only the chats of the token. A token with no label is named `token:token:<id>`, where `<id>` is its number in the token list. Tokens have no account grant; the chat list is the whole scope.

Each token in the list shows a line such as `2 chats · used 5 times · last used yesterday · expires Oct 12`, and "Revoked" or "Expired" when it no longer opens. A revoked token stays in the list, dimmed.

![A share-link session: one chat, placeholders where downloads are off, and the menu naming the session "Shared link"](<https://geiserx.github.io/Telegram-Archive/images/screenshots/share-view.png>)

To end access:

- **Revoke** or **Delete** the token in the list. Either one ends every session the token created and deletes their push subscriptions on this viewer. A second viewer on the same database drops those sessions within 60 seconds. See [A second viewer](<#a-second-viewer>). Changing its chats or its no-download setting through the API does the same.
- Do not rely on expiry for this. Expiry only stops new logins with the token. Sessions it already opened keep working until `AUTH_SESSION_DAYS` runs out.

Each token login checks the presented token against every live token in turn, at about 50 ms per token. Delete tokens you no longer need.

> [!NOTE]
>
> **Share links need the master login**
>
> Share-token sessions and viewer accounts use the session cookie. The viewer checks that cookie only when `VIEWER_USERNAME` and `VIEWER_PASSWORD` are set. With proxy identity alone they cannot be used.

## No-download logins

A viewer account or share token with downloads off can read messages but cannot take files away:

- Media files, thumbnails, chat exports and voice transcripts answer HTTP 403. That covers both export formats, so a no-download login cannot take media away inside an HTML export either.
- A shared contact shows its name and phone, and its vCard text is not sent.
- A photo or a video keeps its place and proportions as a placeholder that says `hidden for this login`, or `hidden for this link` in a session opened through a share link. A voice message keeps its row, with its play button dimmed and "playback off for this login" (or "this link") beside its duration.
- **Export chat** is not offered, and What changed shows no transcripts.
- The lightbox has no download button, and search skips hits that match only inside a transcript.
- The main menu says "downloads off" under the login's name.
- Profile photos and custom emoji still show. A custom emoji is a file from a public emoji pack, the same for every chat, not a file someone sent.

## A second viewer

You can run more than one viewer container against the same archive, for example one for yourself and one limited to a few chats for other people. The stock `docker-compose.yml` has a commented example named `telegram-channel-viewer`. Before you uncomment it, read the points below.

- Both read the same database, so viewer accounts, share tokens, sessions, the audit log and the generated Web Push keys are shared. Each viewer applies its own `DISPLAY_CHAT_IDS`, master login and display settings.
- Browsers send a cookie to every port of a host name. On one host name, a master login on the main viewer is also a master login on the second viewer, and the second viewer only narrows it with its own `DISPLAY_CHAT_IDS`. Give the second viewer its own host name behind the reverse proxy, or use it only for other people.
- Ending sessions reaches every viewer, the other one within 60 seconds. Editing or deleting an account, revoking a token or [ending every session](<#end-every-session>) removes the sessions from the database and from the memory of the viewer you use. The other viewer checks each session it holds against the database again once that check is 60 seconds old. The next request after that fails, and the browser returns to the login. A browser there that only keeps a live connection open, with no requests, is closed by the sweep that runs every 900 seconds. If the database cannot be read, the viewer keeps the session and checks again 10 seconds later.
- On SQLite the backup sends live events to one address, `VIEWER_HOST` and `VIEWER_PORT`. The second viewer gets none. An open chat there picks up new messages through its 3-second poll only, and it sends no notifications. On PostgreSQL both viewers receive every event.

The shipped example publishes port 8001 on every interface. It has no database variables, no time zone and none of the hardening of the stock services. Use this service block under `services:` instead:

```
services:
  telegram-channel-viewer:
    image: drumsergio/telegram-archive-viewer:9.8.0
    container_name: telegram-channel-viewer
    restart: unless-stopped
    ports:
      - "127.0.0.1:8001:8000"
    environment:
      BACKUP_PATH: /data/backups
      DB_TYPE: ${DB_TYPE:-sqlite}
      DB_PATH: ${DB_PATH:-/data/backups/telegram_backup.db}
      DISPLAY_CHAT_IDS: "-1001234567890,-1009876543210"
      VIEWER_USERNAME: public_viewer
      VIEWER_PASSWORD: choose-another-long-password
      VIEWER_TIMEZONE: ${VIEWER_TIMEZONE:-Europe/Madrid}
    volumes:
      - ./data:/data
    networks:
      - telegram-network
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
```

With PostgreSQL, add the `POSTGRES_*` variables as well, the same as on `telegram-viewer`. Put a reverse proxy in front before you open the port to other machines. See [Exposing the viewer safely](<https://geiserx.github.io/Telegram-Archive/viewer/exposing/index.md>).

## How visibility combines

Three rules apply to every request, in this order:

1. `DISPLAY_CHAT_IDS` limits the whole viewer for every role, the master login included.
2. The user's account grant limits it further.
3. The chat grant of the user or token limits it last.

If you write a chat id in `DISPLAY_CHAT_IDS` without its `-100` prefix and only the prefixed form exists in the archive, the viewer corrects it at start. The viewer drops live updates for chats outside the list.

A grant that is not set means no restriction. A grant set to an empty list denies everything. See [No chats ticked means all chats](<#viewer-accounts>) for how the **Admin settings** form saves an empty **Allowed chats** list. A chat you may not see answers the same 404 `Chat not found` as a chat that does not exist, so a restricted user cannot probe for chats.

A restricted user never learns about accounts outside its grant. The account list and the account tags on messages hide them, and the statistics are recomputed from that user's own chats.

Numeric chat and sender ids stay visible to every login. See [What logged-in users can see](<https://geiserx.github.io/Telegram-Archive/viewer/exposing/#what-logged-in-users-can-see>).

## Anonymous mode

`ALLOW_ANONYMOUS_VIEWER=true` opens the viewer to anyone who can reach it. The value must be `true`, in any letter case. Other values such as `1` or `yes` leave it off. It takes effect only when neither the master login nor proxy identity is configured.

Every visitor is logged in as a read-only user named `anonymous` without master rights. It can read every chat allowed by `DISPLAY_CHAT_IDS` and download files. It can also subscribe to push notifications and ask for a voice transcript, which queues a transcription. Transcript asks are limited per client IP by `TRANSCRIPTION_ASK_RATE_LIMIT`, and `TRANSCRIPTION_ASK_MAX_OPEN` caps how many asked files may wait at once. A file that was already transcribed or skipped is not queued again. See [Voice transcription](<https://geiserx.github.io/Telegram-Archive/configuration/transcription/#the-button>).

## Identity from a reverse proxy

With `AUTH_PROXY_HEADER` set, the viewer takes the username from that request header:

| Variable | Default | Effect |
| --- | --- | --- |
| `AUTH_PROXY_HEADER` | unset | Name of the header that carries the username. Setting it turns proxy identity on. |
| `AUTH_PROXY_ADMIN_USERS` | unset | Comma-separated usernames that get the master role. Exact match. |
| `AUTH_PROXY_DEFAULT_ACCESS` | `none` | `all` gives new proxy users every chat. Any other value gives them no chats until the master login grants some. |

On first visit, the viewer creates a viewer account for each proxy user who is not an admin. That account lives in the database, so these requests get HTTP 503 if the database is unreachable. The master login edits its grants in **Viewer Accounts**, the same as any other account. Read the warning in [Viewer accounts](<#viewer-accounts>) first. A disabled account gets HTTP 403.

A request without the header falls back to the session cookie, so password logins keep working next to the proxy when the master login is also set.

> [!CAUTION]
>
> **The proxy must own the header**
>
> The viewer trusts the header as given. Anyone who can send it straight to the viewer can log in as any user, including an admin. The proxy must strip or overwrite that header on every request, and the viewer must not be reachable around the proxy. See [Exposing the viewer safely](<https://geiserx.github.io/Telegram-Archive/viewer/exposing/index.md>).

## Single sign-on with OpenID Connect

With the four required variables set, the login page shows **Sign in with single sign-on**. It runs the authorization code flow with PKCE against your provider (Cloudflare Access, Authentik, Keycloak and others), then opens the same session a password login does. Password login keeps working beside it, and it needs `VIEWER_USERNAME` and `VIEWER_PASSWORD` set, because the session is the same one.

| Variable | Default | Effect |
| --- | --- | --- |
| `OIDC_ISSUER` | unset | Issuer URL. The viewer reads `/.well-known/openid-configuration` under it. Must be `https`. |
| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | unset | The client registered for the viewer. They are sent to the token endpoint as HTTP Basic (`client_secret_basic`), or in the request body when the provider's discovery document lists `client_secret_post` and not basic. |
| `OIDC_REDIRECT_URI` | unset | The public callback, `https://<viewer host>/api/oidc/callback`. Register exactly this at the provider. |
| `OIDC_SCOPES` | `openid email profile` | Scopes requested. |
| `OIDC_USERNAME_CLAIM` | `email` | ID-token claim that names the user. Matched lowercase. |
| `OIDC_ADMIN_USERS` | unset | Comma-separated claim values that get the master role. The session is named after the claim value. Taking a value out and restarting ends that identity's master sessions on their next request. |
| `OIDC_USER_MAP` | unset | Comma-separated `claim-value=account` pairs that sign a person in as the named account, created on first sign-in when it does not exist yet. The account name is matched exactly, same case as the account. Pointing one at `VIEWER_USERNAME` gives the master role, and the session is then named `VIEWER_USERNAME`. Removing such an entry does not end that person's master session; [end every session](<#end-every-session>). |
| `OIDC_DEFAULT_ACCESS` | `none` | Like `AUTH_PROXY_DEFAULT_ACCESS`, for any identity the provider lets through that has no account yet. |

An identity that is not an admin and not mapped gets a viewer account on first sign-in, named after the claim value. An unmapped identity that spells the master's name, a proxy admin, `anonymous`, an `OIDC_USER_MAP` target, a password account or a name starting with `token:` (the names share-link sessions use) is refused, so a provider that lets users choose their own `preferred_username` cannot hand out the master role or someone else's account. A disabled account gets HTTP 403. A failed sign-in returns to the login page with a short message.

The `X-Viewer-Only: true` rule applies here too: a single sign-on that would give the master role through such a request is refused.

**Sign out** ends the viewer session as before. When the provider's discovery document has an `end_session_endpoint`, the browser then goes there to end the provider's session too, with `client_id` and a `post_logout_redirect_uri` of the viewer's root (`OIDC_REDIRECT_URI` without `/api/oidc/callback`, for example `https://viewer.example/`). Register that address as a post-logout redirect URI at the provider, or the provider shows its own error page after sign-out. Without an `end_session_endpoint` the provider's session stays open, and on a shared browser the next **Sign in with single sign-on** signs the same person straight back in.

With the `email` claim, an address is refused only when the provider sends `email_verified` as false. A token with no `email_verified` claim is accepted, because some providers, Cloudflare Access among them, never send it. Authentik's default email scope sends `email_verified: false`, so add Authentik's documented custom scope mapping that sets it true, or every login is refused.

> [!CAUTION]
>
> **Pick a claim users cannot edit**
>
> `OIDC_ADMIN_USERS` and `OIDC_USER_MAP` are matched against whatever claim `OIDC_USERNAME_CLAIM` names, and the viewer trusts that value as the provider sends it. Choose a claim the provider keeps unique and that users and tenant admins cannot change. On Microsoft Entra ID set `OIDC_USERNAME_CLAIM=oid` (or `sub`): Entra's `email`, `preferred_username` and `upn` can be changed, and Entra sends no `email_verified`.

> [!CAUTION]
>
> **The provider decides who may sign in**
>
> The viewer does not keep its own list of who may sign in. Every identity the issuer authenticates gets an account, so the provider's access policy for this client is the only gate: an Authentik application policy, a Keycloak client role, a Cloudflare Access policy or a Google Workspace app set to Internal. With a public issuer such as `https://accounts.google.com` and `OIDC_DEFAULT_ACCESS=all`, anyone with an account at that provider can read every chat.

The viewer reads the ID token straight from the provider's token endpoint over TLS and does not check its signature. It does check issuer, audience, expiry and the per-login nonce, and the login state travels in a signed, short-lived cookie.

## The audit log

The viewer records these events in the database:

- Logins, failed logins and logouts.
- Share-token logins, successful and failed.
- Admin changes: viewer accounts created, updated or deleted, share tokens created, updated or deleted, settings changed, and every session ended.

Password login entries carry the username, role, client IP and browser user agent. Share-token entries carry the client IP but no user agent. The viewer does not log ordinary reads of chats and messages.

The master login reads the log in **Admin settings**, tab **Activity**. It shows the 200 newest entries in words, for example "Signed in" or "Sign-in failed", each with the user, the role, the address and the time, and chips pick all of them, sign-ins, failed sign-ins or changes. The API route `GET /api/admin/audit` returns up to 500 entries per request and filters by `username` and `action`.

Behind a reverse proxy, every entry shows the proxy's IP by default. Login and share-token entries can record the real client IP if the viewer trusts forwarded headers. See [Exposing the viewer safely](<https://geiserx.github.io/Telegram-Archive/viewer/exposing/index.md>). Logout and admin-change entries always show the proxy's IP.
