Clean, professional, encrypted paste service in one binary.
Clpaste runs in one of three modes, from a classic open pastebin to a full identity-provider setup:
- Open (
--unprotected): a public pastebin where everyone is equal. Anyone may create and view public pastes without signing in; nobody gets metadata pages or admin functions. (The basic-auth admin account of mode 2 still exists for oversight — the pastes list and expire/delete.) - HTTP basic auth (the default when no OIDC provider is
configured): whoever signs in with the admin account is an admin —
they create pastes, see the list and manage everything; everyone
else is a guest who can only view pastes. The password is
auto-generated and printed at startup; set
CLPASTE_ADMIN_PASSWORDto make it permanent. - OIDC: sign-in through your identity provider (Keycloak, Authentik, Entra, Google, …) gives the full three roles — admins (picked by email, domain or claim), users (signed in, not admins) who create and share pastes, and guests (not signed in) who view them.
In every mode, viewing a paste is open to anyone subject to whatever restrictions the author put on it. Every option you set tightens access; nothing loosens it.
- Web UI (clean Bootstrap page, light/dark) and a CLI (
clpaste put/clpaste get) that authenticates gcloud-style (clpaste loginonce). - Per-paste protection: PIN (default on), password, max views, expiry time, guest/user/admin visibility with an optional email restriction, IP/CIDR allowlist, CLI-only, expiry after N failed attempts.
- Per-role permissions: the author decides, per paste, what the author, admins and other signed-in users may do with it — manage it (expire/delete) and peek at the content without consuming a view (logged; both author and admins only), and see its metadata & audit log. No built-in exemptions — even admins only get what the paste grants (since 0.3.0; before that the author and admins were always exempt).
- Audit log of who viewed what and when (identity from OIDC when available; IPs only if the author opted in). The log outlives the paste (until deletion, if the paste has it set).
- Expiry immediately when a limit is hit, plus an hourly sweep for time-expired pastes. Viewers are told what remains or that it is gone.
- Deletion (optional): unlike expiry, removes every trace — record, metadata and audit log — N hours after expiry, or, anchored to views instead, N hours after the last counted view (0 = the moment it is viewed). Whoever holds the paste's manage permission can also expire or delete it at any time from its settings + log page.
- Pastes page: users see their own and shared pastes with protection badges; admins see every paste, get Peek as admin / View / Expire / Delete on the per-paste settings + log page. The navbar ID box says View and opens the paste; for admins it says Find and jumps to the paste's settings + log page.
- Encrypted at rest: AES-256-GCM envelope encryption; password-protected pastes wrap their key with the password, so admins cannot read them.
- SQLite or PostgreSQL. All settings via env vars / flags / config file.
Ad hoc, zero configuration:
shards build --release # needs: libssl, libsqlite3, libxml2, libyaml, libpcre2 (-dev) + pkg-config
bin/clpaste serveThat runs on http://localhost:8080 with SQLite in ./clpaste.db, a master key
generated into ./clpaste.key (back it up), and — since no OIDC provider is
configured — an admin user with a random password printed at startup
(HTTP basic auth; set CLPASTE_ADMIN_PASSWORD to make it permanent). The
browser prompts for it on the paste form and the admin pages; viewing a
paste needs no login. Add --unprotected to let guests create public
pastes.
Production, with your identity provider:
export CLPASTE_MASTER_KEY=$(bin/clpaste keygen)
export CLPASTE_OIDC_ISSUER=https://login.example.com/realms/main
export CLPASTE_OIDC_CLIENT_ID=clpaste CLPASTE_OIDC_CLIENT_SECRET=...
export CLPASTE_ADMIN_EMAILS=you@example.com
bin/clpaste serveRegister https://paste.example.com/auth/callback as the redirect URI in your
OIDC provider (Keycloak, Authentik, Entra, Google, … — anything with standard
discovery works).
Clpaste needs no hostname configuration: links, the OIDC redirect URI and the
Secure cookie flag are derived from each request's Host header, and from
X-Forwarded-Proto/X-Forwarded-Host when the request comes from an IP in
CLPASTE_TRUSTED_PROXIES (so put your reverse proxy there when it terminates
TLS). Set CLPASTE_BASE_URL only to pin one canonical URL. A request without
a usable Host gets plain paths (/p/123-456-789) instead of absolute links.
Docker: cp .env.example .env, fill it in, docker compose up -d. The image
is a static Alpine build; data lives in the /data volume (SQLite) or in
PostgreSQL.
Clpaste reads the standard libpq variables plus the usual POSTGRES_*
bootstrap secrets, so the settings you already have for other services work
unchanged:
| Variable | Meaning | Default |
|---|---|---|
PGHOST |
host, or a socket directory when it starts with / |
/var/run/postgresql |
PGPORT |
port | 5432 |
PGUSER |
app role | clpaste |
POSTGRES_USER_PASSWORD |
app role password (PGPASSWORD / ~/.pgpass also work) |
— |
PGDATABASE |
database name | clpaste |
PGSSLMODE |
disable/prefer/require/… |
prefer |
POSTGRES_USER, POSTGRES_PASSWORD |
superuser for bootstrap (optional) | postgres, — |
Setting any of these (with CLPASTE_DB_URL unset) selects PostgreSQL; an
explicit CLPASTE_DB_URL=postgres://… always wins. At startup:
- if
POSTGRES_PASSWORDis set, Clpaste connects as the superuser totemplate1, creates the app role if missing or converges it (LOGIN CREATEDB PASSWORD …, so a rotated secret takes effect), and creates the database owned by that role; - otherwise it connects as the app role and, if the database does not
exist, creates it from
template1itself (the role needsCREATEDB).
Tables are created on first connection. Nothing else is needed.
CLPASTE_ADMIN_USER / CLPASTE_ADMIN_PASSWORD enable HTTP basic auth that
grants the admin role on every route (the pastes list, the paste form, the
JSON API via curl -u). With OIDC configured as well, /admin (an alias
that redirects to /pastes) and /login?basic=1 challenge for basic auth
while everything else redirects to OIDC. Browsers cache basic credentials until the window is
closed — there is no server-side logout for them.
clpaste login --server https://paste.example.com # opens the browser; --no-browser prints a URL + asks for a code
clpaste whoami
echo "secret" | clpaste put # guests, PIN on, 24h, 1 view (server defaults; users/admins default to unlimited)
clpaste put report.pdf notes.txt --text "see attached" --views 1 --ttl 2
clpaste put --guests --pin 4321 --password hunter2 --ips "203.0.113.0/24 198.51.100.7" --cli-only --max-failures 3
clpaste put --users --emails bob@example.com,eve@example.com --team-meta --log-ips
clpaste put --json ... # machine-readable {id, id_fmt, url, pin, ...}
clpaste get 123-456-789 # prompts for PIN/password if needed; text -> stdout,
clpaste get 123456789 --pin 4321 -o ./downloads # attachments -> files, status -> stderr
clpaste logoutGuest pastes need no login to get. Viewing with plain curl:
curl -H 'X-Clpaste-Pin: 4321' https://paste.example.com/api/pastes/123456789(X-Clpaste-Client: anything marks a request as CLI for --cli-only pastes;
Authorization: Bearer <token> for private ones.)
Every option is available as an env var (CLPASTE_<KEY>), a flag
(--<key>), and a key in ~/.config/clpaste/config.yml (or --config FILE);
clpaste config prints the effective values with their sources. The full
list with defaults is in clpaste --help below.
Notable ones:
master_key/key_file— the 32-byte key everything is encrypted with. Losing it loses every paste. Generate withclpaste keygen.unprotected— guests may create public pastes without signing in; the visibility and permission options disappear;/pastes, paste details and manual expiry require an admin. Guest creation is rate limited like viewing.admin_emails/admin_domains/admin_claim— who gets the admin role on OIDC login (any rule suffices). Withadmin_domainsset, every other signed-in user is a plain user: they may create pastes and view them like any guest, but the permission options and the/pastespages are gone. The first admin domain also completes bare account names in email restrictions ("bob" means bob@your-first-domain).default_max_views_public/_private(1/ unlimited),default_ttl_hours(24),default_pin(on),default_max_failures(3) — what the form and CLI start with.base_url— normally empty (derived from the request);trusted_proxies— CIDRs whoseX-Forwarded-*headers are honoured.theme_dir— override templates and static files at runtime.show_meta(on) — viewers are told who a paste is from and since when, and expired pastes say why and when they expired; off makes both generic.show_version(on) — print the clpaste version in the page footer.
- IDs are random decimal numbers (9 digits by default), shown as
123-456-789; dashes and spaces are ignored on input, anywhere. - Storage is deliberately opaque:
pastes(id, state, created_at, expires_at, meta, body).metais JSON (settings, hashed PIN, wrapped key, counters) encrypted with the master key;bodyis JSON (text + base64 attachments) encrypted with a per-paste random key. The key is wrapped with the master key — or with a PBKDF2-derived key when the paste has a password, in which case nobody without the password can read it, admins included. Onlystateandexpires_atare queryable; everything else is parsed in the app, so SQLite and PostgreSQL behave identically. - Viewing (
/p/ID,/api/pastes/ID) checks, in order: already expired? past its time limit? CLI-only? IP allowed? login/admin/email required? PIN? password? Then the view is counted, the paste expires if the limit is reached, and the viewer is told what remains. Wrong PIN/password bumps a failure counter — per(paste, IP)if the paste logs IPs, per paste otherwise — and expires the paste when it reaches the paste's limit. Every view attempt — web or API, valid ID or not — counts againstrate_limit(per client IP per minute, default 10; admins are exempt), so the ID space cannot be scanned. - Peeks (
/pastes/ID/view,/pastes/ID/admin-view) show the content without counting a view; they are logged. What a role may do — manage the paste (expire/delete), open its settings + log page, peek — is set per paste on the Permissions card, separately for the author and for admins; other signed-in users can never peek, only view (counted), and may at most see metadata (--team-meta). Defaults: the author may do all three, admins may manage and see metadata, users nothing. There are no built-in exemptions: a paste that grants admins nothing shows admins nothing beyond its/pasteslist row. Someone holding manage but not meta gets a friendly page with just the Expire/Delete buttons. Withadmin_domainsset, plain users only create and view; the permission options are admin-only. (Audit rows written before 0.2.0 use the old action namesteam_meta/team_viewand creatoranonymousinstead ofuser_meta/user_viewandguest.) - Expiry deletes the body, the wrapped key and the PIN/password secrets; the descriptive settings, counters and the audit log survive (until deletion), so the settings + log page reads the same for expired pastes. Later access attempts are logged as denied.
- Deletion, when set on a paste, later removes that residual record and the audit log entirely (the ID then reads "No such paste"). The deadline arms at expiry, or — with "delete after last view" — is (re)set by every counted view; 0 hours means immediately. Due deletions happen at the triggering event or on the hourly sweep.
- CLI login mirrors
gcloud auth login: the CLI opensBASE_URL/cli/auth?port=…&state=…&challenge=…, the server runs the OIDC flow (the CLI never holds OIDC secrets), then redirects the browser to127.0.0.1:<port>/callback?code=…. The CLI exchanges the one-time code plus the PKCE-style verifier for a bearer token (token_ttl). With--no-browserthe server shows the code on a page instead. - OIDC uses discovery and the authorization-code flow over the
back-channel; the id_token comes straight from the token endpoint over
TLS, so its
iss/aud/exp/nonceare validated and identity is confirmed viauserinfo(OIDC Core §3.1.3.7 permits skipping signature verification in this case). /healthzanswersok clpaste <version> <git-sha>, so you can always check what a running instance was built from (the footer's version number carries the same sha in its tooltip).- Limits of "CLI-only": it is enforced by requiring the
X-Clpaste-Clientheader, which any HTTP client can send. Treat it as a convenience to keep casual browser access out, not as a security boundary.
Copy any of templates/*.html (Jinja2 syntax via Crinja) or the files under
assets/ into CLPASTE_THEME_DIR (templates at the top level, static files
under static/); files found there win over the built-in ones. The layout
uses Bootstrap 5.3's data-bs-theme for colour modes.
The built-in templates also stay addressable as builtin/<name>, so a theme
template that shadows a name can extend the stock one instead of forking it.
layout.html exposes blocks for the usual brandings — head_extra (extra
tags at the end of <head>), navbar_class, navbar_attrs and
navbar_brand — so a themed navbar is just:
{% extends "builtin/layout.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/my.css">{% endblock %}
{% block navbar_class %}navbar navbar-expand my-navbar mb-4{% endblock %}
{% block navbar_attrs %} data-bs-theme="dark"{% endblock %}
{% block navbar_brand %}<a class="navbar-brand" href="/">
<img src="/static/my-logo.svg" alt="" height="22"> {{ site_name }}</a>{% endblock %}Static files referenced this way go in CLPASTE_THEME_DIR/static/ and are
served at /static/<name> (flat names; css/js/png/svg/ico get their proper
Content-Type).
shards install
crystal spec # unit + service + HTTP specs (with a built-in fake OIDC provider)
crystal build src/clpaste.cr -o bin/clpasteOn Debian/Ubuntu the build needs pkg-config libssl-dev libsqlite3-dev libxml2-dev libyaml-dev libpcre2-dev. A fully static binary is easiest on
Alpine (see Dockerfile).
AGPL-3.0 — see LICENSE.
clpaste 0.3.0 — encrypted paste service
Usage: clpaste <command> [options]
Server:
serve Run the web/API server (needs --master-key / CLPASTE_MASTER_KEY)
keygen Print a fresh random master key
config Dump effective configuration
Client:
login [--server URL] [--no-browser]
Sign in via the server's OIDC app (browser), store a CLI token
logout Revoke the stored token
whoami Show who you are logged in as
put [FILE...] [options] Create a paste; text is read from stdin unless --text is given
get ID [options] View a paste; prints text, saves attachments
Run `clpaste <command> --help` for command options.
Server options (flags for `clpaste serve`; also environment variables, or keys in /home/user/.config/clpaste/config.yml / --config FILE):
--config FILE Load a YAML/JSON config file
--dump-config [FORMAT] Print the effective configuration (yaml|json|env|pretty|report) and exit
--admin-claim VALUE CLPASTE_ADMIN_CLAIM Alternative admin rule: CLAIM=VALUE (e.g. groups=clpaste-admins); matched against id_token/userinfo [default: empty]
--admin-domains VALUE CLPASTE_ADMIN_DOMAINS Comma-separated email domains whose users are admins. When set, other signed-in users are plain users: they may create pastes and view them like guests, but get no team pages [default: empty]
--admin-emails VALUE CLPASTE_ADMIN_EMAILS Comma-separated emails with admin rights [default: empty]
--admin-password VALUE CLPASTE_ADMIN_PASSWORD HTTP basic-auth admin password (enables basic auth; auto-generated and printed at startup when OIDC is not configured) [default: empty]
--admin-user VALUE CLPASTE_ADMIN_USER HTTP basic-auth admin user [default: admin]
--base-url VALUE CLPASTE_BASE_URL Public URL override (e.g. https://paste.example.com). Empty = derived per request from the Host header (and X-Forwarded-Proto/Host from trusted proxies) [default: empty]
--bind VALUE CLPASTE_BIND Address to listen on [default: 0.0.0.0]
--cli-header VALUE CLPASTE_CLI_HEADER Header a CLI client must send to view cli-only pastes [default: X-Clpaste-Client]
--color-mode VALUE CLPASTE_COLOR_MODE Bootstrap color mode: auto|light|dark [default: auto]
--credentials-file VALUE CLPASTE_CREDENTIALS_FILE (CLI) Path of the credentials file (default ~/.config/clpaste/credentials.json) [default: empty]
--db-url VALUE CLPASTE_DB_URL Database URL (sqlite3://PATH or postgres://user:pass@host/db). Empty = PostgreSQL from PG*/POSTGRES_* vars if any are set, else sqlite3://./clpaste.db [default: empty]
--default-max-failures VALUE CLPASTE_DEFAULT_MAX_FAILURES Default number of failed PIN/password attempts before expiry (0 = unlimited) [default: 3]
--default-max-views-private VALUE CLPASTE_DEFAULT_MAX_VIEWS_PRIVATE Default maximum views for user/admin pastes (0 = unlimited) [default: 0]
--default-max-views-public VALUE CLPASTE_DEFAULT_MAX_VIEWS_PUBLIC Default maximum views for guest (no-login) pastes (0 = unlimited) [default: 1]
--default-pin / --no-default-pin CLPASTE_DEFAULT_PIN Whether the PIN option is on by default in the form [default: true]
--default-team-meta / --no-default-team-meta CLPASTE_DEFAULT_TEAM_META Whether 'users can see metadata & audit log' is on by default for API/CLI pastes (the web form no longer offers it) [default: false]
--default-ttl-hours VALUE CLPASTE_DEFAULT_TTL_HOURS Default expiry in hours (0 = never) [default: 24.0]
--id-digits VALUE CLPASTE_ID_DIGITS Number of decimal digits in paste IDs [default: 9]
--key-file VALUE CLPASTE_KEY_FILE Where the master key is stored/generated when master_key is not set [default: clpaste.key]
--log-level VALUE CLPASTE_LOG_LEVEL Log level (trace|debug|info|warn|error) [default: info]
--master-key VALUE CLPASTE_MASTER_KEY 32-byte master encryption key, hex (64 chars) or base64. Empty = load/generate key_file. [default: empty]
--max-attachments VALUE CLPASTE_MAX_ATTACHMENTS Maximum number of attachments per paste [default: 10]
--max-attachment-size VALUE CLPASTE_MAX_ATTACHMENT_SIZE Maximum size of a single attachment in bytes [default: 104857600]
--max-body-size VALUE CLPASTE_MAX_BODY_SIZE Maximum total size of one paste in bytes (text + all attachments) [default: 104857600]
--oidc-auth-method VALUE CLPASTE_OIDC_AUTH_METHOD Token endpoint auth: basic|post [default: basic]
--oidc-client-id VALUE CLPASTE_OIDC_CLIENT_ID OIDC client id [default: empty]
--oidc-client-secret VALUE CLPASTE_OIDC_CLIENT_SECRET OIDC client secret [default: empty]
--oidc-issuer VALUE CLPASTE_OIDC_ISSUER OIDC issuer URL (discovery at ISSUER/.well-known/openid-configuration) [default: empty]
--oidc-scopes VALUE CLPASTE_OIDC_SCOPES OIDC scopes [default: openid email profile]
--port VALUE CLPASTE_PORT Port to listen on [default: 8080]
--rate-limit VALUE CLPASTE_RATE_LIMIT Max view attempts per client IP per minute for non-admins (admins are exempt; 0 = unlimited) [default: 10]
--server VALUE CLPASTE_SERVER (CLI) Server URL; defaults to the one saved by `clpaste login` [default: empty]
--session-ttl VALUE CLPASTE_SESSION_TTL Web session lifetime [default: 43200]
--show-meta / --no-show-meta CLPASTE_SHOW_META Tell viewers who a paste is from and since when, and why/when an expired paste expired [default: true]
--show-version / --no-show-version CLPASTE_SHOW_VERSION Show the clpaste version in the page footer [default: true]
--site-name VALUE CLPASTE_SITE_NAME Site name shown in the UI [default: clpaste]
--sweep-interval VALUE CLPASTE_SWEEP_INTERVAL How often expired pastes are purged [default: 3600]
--theme-dir VALUE CLPASTE_THEME_DIR Directory overriding built-in templates (*.html) and static files (static/*) [default: empty]
--ticket-ttl VALUE CLPASTE_TICKET_TTL How long attachment download links stay valid after a successful web view [default: 1800]
--token-ttl VALUE CLPASTE_TOKEN_TTL CLI token lifetime [default: 7776000]
--trusted-proxies VALUE CLPASTE_TRUSTED_PROXIES Comma-separated IPs/CIDRs whose X-Forwarded-For is trusted [default: empty]
--unprotected / --no-unprotected CLPASTE_UNPROTECTED Guests can create public pastes without signing in; private/team features are hidden and listing pastes requires an admin [default: false]
--pg-database VALUE PGDATABASE Database name (default clpaste); created if missing [default: empty]
--pg-host VALUE PGHOST PostgreSQL host, or socket directory when it starts with / (default /var/run/postgresql) [default: empty]
--pg-port VALUE PGPORT PostgreSQL port [default: 5432]
--pg-sslmode VALUE PGSSLMODE disable|prefer|require|verify-ca|verify-full [default: empty]
--pg-user VALUE PGUSER PostgreSQL app role (default clpaste) [default: empty]
--pg-superuser-password VALUE POSTGRES_PASSWORD If set, the app role (LOGIN CREATEDB, password converged) and the database are created as the superuser at startup [default: empty]
--pg-superuser VALUE POSTGRES_USER Superuser role used for bootstrap [default: postgres]
--pg-password VALUE POSTGRES_USER_PASSWORD App role password (PGPASSWORD and ~/.pgpass are honoured too) [default: empty]
$ clpaste put --help
Usage: clpaste put [FILE...] [options]
Text is read from stdin unless --text is given.
-t, --text TEXT Paste text (instead of stdin)
--no-text Attach files only, don't read stdin
--title T Title
--guests Guest paste: no login needed to view (default)
--users Viewing requires a signed-in user
--admins Viewing requires an admin
--public Alias for --guests
--private Alias for --users
--emails LIST Users/Admins only: restrict to these emails, comma-separated (empty = unrestricted)
--ips LIST Allowed IPs/CIDRs, space-separated (quote the list)
--pin PIN PIN (4-8 digits; default: random 4-digit PIN)
--no-pin Disable PIN
--password PW Password-protect (also hides content from admins)
--views N Max views (0 = unlimited; server default: unlimited for private, 1 for public)
--ttl HOURS Expiry in hours (0 = never; default from server)
--max-failures N Max view (PIN/password) failures before expiry (0 = no limit)
--delete-after HOURS Delete the paste record (incl. audit log) this many hours after expiry (0 = at once; default: never)
--delete-on-retrieval Anchor deletion to views instead: each counted view restarts the timer (0 = delete when viewed)
--cli-only Viewable only via CLI
--team-meta Users may see metadata & audit log (server default: off)
--no-team-meta Hide metadata & audit log from other users
--no-author-meta Author may not see metadata & audit log (default: may)
--author-view Author may peek the content (default: not)
--no-author-manage Author may not expire/delete the paste (default: may)
--no-admin-meta Admins may not see metadata & audit log (default: may)
--admin-view Admins may peek the content (default: not)
--no-admin-manage Admins may not expire/delete the paste (default: may)
--log-ips Record viewer IPs in the audit log
--json Machine-readable output
-h, --help Help
$ clpaste get --help
Usage: clpaste get ID [options]
Text goes to stdout, status to stderr, attachments to files.
--pin PIN PIN (prompted if needed)
--password PW Password (prompted if needed)
-o, --out DIR Directory for attachments (default .)
-f, --force Overwrite existing files
--text-only Don't save attachments
--json Print the raw JSON response
-h, --help Help


