Build a Pastebin Clone in Go With Expiring Share Links
Build a pastebin in Go on SQLite with unguessable IDs, expiry, burn after read that survives link previews, delete tokens and output that cannot run scripts.

A pastebin looks like the easiest possible web service: a text box, a button and a URL. It is also a service whose entire content is strangers' text, some of it malicious, shared by link and often expected to disappear. That mix makes it a good project. The code is small, and nearly every decision in it is a security or privacy decision: how a paste is addressed, how it is displayed, when it dies, and who may kill it early.
Tested with Go 1.25.6 and modernc.org/sqlite v1.50.0, macOS on Apple Silicon (arm64)
This project builds one on SQLite with expiry, burn after read and delete tokens, and tests the properties that are easy to get wrong, including a race between readers of a burn-after-read paste and a script injected through the paste body. It belongs to the web cluster that ends in a production HTTP server, uses the storage ideas from the URL shortener, and the output rules come from the HTML template article. Everything ran on Go 1.25.6 with modernc.org/sqlite v1.50.0.
What this deliberately leaves out
There are no accounts, no listing page of recent pastes and no search. Each of those is a feature people ask for and each changes the threat model: a listing makes every paste public, and an account system adds passwords, sessions and a way to link pastes to people. A pastebin that only serves content to people holding the exact URL is much easier to reason about, to host and to defend. If you add a listing, make it opt-in per paste, and keep the unlisted default.
Decisions before code
The address is the secret. A paste has no owner and no password. Anyone with the URL can read it, so the URL must not be guessable. The shortener's seven-character code is fine for public links and wrong here; a counter is worse. Use 16 bytes from crypto/rand, encoded in URL-safe base64, which gives a 22-character ID and 128 bits of unguessability. Nobody will type it, and that is the point.
Every paste expires. A free text host with no expiry fills up with abuse and old secrets. Offer a few lifetimes (ten minutes, an hour, a day, a week) and refuse to store forever. Expiry is also what makes the service safer to run, since anything that was pasted by mistake disappears by itself.
Burn after read is a feature with a trap. The promise is that the first reader sees it and nobody else can. The trap is that the first fetch of a URL is often not a person: chat apps and social sites fetch links to build previews, mail scanners fetch them to check for malware, and browsers prefetch. If a plain GET consumes the paste, a bot burns it before the intended reader arrives. The fix is an interstitial page that explains the paste will be destroyed, with a button that sends a POST. Bots follow links, and they do not click buttons.
The content is hostile. A paste body can hold <script> tags, HTML that imitates a login form, a file that a browser might sniff as something executable. The page that shows it must not interpret it.
The store
package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"errors"
"time"
_ "modernc.org/sqlite"
)
var ErrNotFound = errors.New("paste not found")
type Paste struct {
ID string
Title string
Body string
Burn bool
CreatedAt time.Time
ExpiresAt time.Time
}
type Store struct{ db *sql.DB }
func OpenStore(dsn string) (*Store, error) {
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS pastes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
burn INTEGER NOT NULL,
delete_hash BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)`)
if err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
// token returns 16 random bytes as URL-safe base64.
func token() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// Create stores a paste and returns its ID and the delete token. Only a hash of the token is kept.
func (s *Store) Create(ctx context.Context, p Paste) (id, deleteToken string, err error) {
if id, err = token(); err != nil {
return "", "", err
}
if deleteToken, err = token(); err != nil {
return "", "", err
}
hash := sha256.Sum256([]byte(deleteToken))
_, err = s.db.ExecContext(ctx,
`INSERT INTO pastes (id, title, body, burn, delete_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, p.Title, p.Body, boolInt(p.Burn), hash[:], p.CreatedAt.Unix(), p.ExpiresAt.Unix())
return id, deleteToken, err
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// Peek reads a paste without consuming it; for a burning paste it reveals only that it exists.
func (s *Store) Peek(ctx context.Context, id string, now time.Time) (Paste, error) {
var p Paste
var burn int
var created, expires int64
err := s.db.QueryRowContext(ctx,
`SELECT id, title, body, burn, created_at, expires_at FROM pastes WHERE id = ? AND expires_at > ?`, id, now.Unix()).
Scan(&p.ID, &p.Title, &p.Body, &burn, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return Paste{}, ErrNotFound
}
if err != nil {
return Paste{}, err
}
p.Burn, p.CreatedAt, p.ExpiresAt = burn == 1, time.Unix(created, 0), time.Unix(expires, 0)
if p.Burn {
p.Body = "" // a peek never exposes the content of a burning paste
}
return p, nil
}
// Take returns the paste and, if it burns after reading, deletes it in the same statement. Exactly one caller can win.
func (s *Store) Take(ctx context.Context, id string, now time.Time) (Paste, error) {
var p Paste
var created, expires int64
err := s.db.QueryRowContext(ctx,
`DELETE FROM pastes WHERE id = ? AND burn = 1 AND expires_at > ? RETURNING id, title, body, created_at, expires_at`,
id, now.Unix()).Scan(&p.ID, &p.Title, &p.Body, &created, &expires)
if err == nil {
p.Burn, p.CreatedAt, p.ExpiresAt = true, time.Unix(created, 0), time.Unix(expires, 0)
return p, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return Paste{}, err
}
// Not a burning paste (or already gone): an ordinary read.
var burn int
err = s.db.QueryRowContext(ctx,
`SELECT id, title, body, burn, created_at, expires_at FROM pastes WHERE id = ? AND burn = 0 AND expires_at > ?`, id, now.Unix()).
Scan(&p.ID, &p.Title, &p.Body, &burn, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return Paste{}, ErrNotFound
}
p.CreatedAt, p.ExpiresAt = time.Unix(created, 0), time.Unix(expires, 0)
return p, err
}
// Delete removes a paste if the token matches. The hash is compared in constant time.
func (s *Store) Delete(ctx context.Context, id, tok string) (bool, error) {
var stored []byte
err := s.db.QueryRowContext(ctx, `SELECT delete_hash FROM pastes WHERE id = ?`, id).Scan(&stored)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
given := sha256.Sum256([]byte(tok))
if subtle.ConstantTimeCompare(stored, given[:]) != 1 {
return false, nil
}
_, err = s.db.ExecContext(ctx, `DELETE FROM pastes WHERE id = ?`, id)
return err == nil, err
}
// Purge deletes everything that has expired and returns how many rows went.
func (s *Store) Purge(ctx context.Context, now time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM pastes WHERE expires_at <= ?`, now.Unix())
if err != nil {
return 0, err
}
return res.RowsAffected()
}Take is the interesting method. For a burning paste, DELETE ... RETURNING removes the row and hands back its content in one atomic step: of ten readers racing for the same paste, the database serializes them, the first gets the row and the rest get nothing. A read-then-delete in two statements would let several readers see the content before any delete ran. If the paste does not burn, the first statement matches nothing and the fallback SELECT does an ordinary read.
Peek is what the interstitial uses. It confirms the paste exists and is not expired, and empties the body for a burning paste so no code path can leak it before the click. The delete token is a second 16-byte secret returned once at creation; only its SHA-256 is stored, so a leaked database does not allow anyone to delete pastes. The comparison of two hashes in constant time is belt and braces, since both sides are hashes, but it costs nothing and leaves no timing question to answer.
The HTTP layer
package main
import (
"encoding/json"
"errors"
"html/template"
"net/http"
"strings"
"time"
"unicode/utf8"
)
const maxBody = 256 << 10
var lifetimes = map[string]time.Duration{"10m": 10 * time.Minute, "1h": time.Hour, "1d": 24 * time.Hour, "1w": 7 * 24 * time.Hour}
var viewTmpl = template.Must(template.New("view").Parse(`<!doctype html><meta charset="utf-8">
<title>{{.Title}}</title>
<h1>{{.Title}}</h1>
<p>{{if .Burn}}This paste has been destroyed.{{else}}Expires {{.ExpiresAt.Format "2006-01-02 15:04 MST"}}.{{end}}</p>
<pre>{{.Body}}</pre>`))
var burnTmpl = template.Must(template.New("burn").Parse(`<!doctype html><meta charset="utf-8">
<title>Burn after reading</title>
<p>This paste is destroyed the moment it is shown. Continue only if you are the intended reader.</p>
<form method="post"><button>Show it and destroy it</button></form>`))
type Server struct {
Store *Store
Now func() time.Time
Base string
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/pastes", s.create)
mux.HandleFunc("DELETE /api/pastes/{id}", s.remove)
mux.HandleFunc("GET /p/{id}", s.view)
mux.HandleFunc("POST /p/{id}", s.reveal)
mux.HandleFunc("GET /p/{id}/raw", s.raw)
return mux
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func (s *Server) create(w http.ResponseWriter, r *http.Request) {
var in struct {
Title string `json:"title"`
Body string `json:"body"`
Expires string `json:"expires"`
Burn bool `json:"burn"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBody+4096)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be JSON, at most 256 KB"})
return
}
life, ok := lifetimes[in.Expires]
switch {
case in.Body == "" || len(in.Body) > maxBody || !utf8.ValidString(in.Body) || strings.ContainsRune(in.Body, 0):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be non-empty UTF-8 text under 256 KB"})
return
case !ok:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expires must be one of 10m, 1h, 1d, 1w"})
return
case utf8.RuneCountInString(in.Title) > 100:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is limited to 100 characters"})
return
}
if in.Title == "" {
in.Title = "Untitled"
}
now := s.Now()
id, tok, err := s.Store.Create(r.Context(), Paste{Title: in.Title, Body: in.Body, Burn: in.Burn, CreatedAt: now, ExpiresAt: now.Add(life)})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not store the paste"})
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "url": s.Base + "/p/" + id, "delete_token": tok, "burn": in.Burn})
}
// secure sets the headers that stop a browser from treating pasted text as anything but text.
func secure(w http.ResponseWriter) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'none'; frame-ancestors 'none'")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Cache-Control", "no-store")
}
func (s *Server) view(w http.ResponseWriter, r *http.Request) {
p, err := s.Store.Peek(r.Context(), r.PathValue("id"), s.Now())
if errors.Is(err, ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
secure(w)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if p.Burn {
burnTmpl.Execute(w, nil) // a GET never consumes a burning paste
return
}
p, err = s.Store.Take(r.Context(), p.ID, s.Now())
if err != nil {
http.NotFound(w, r)
return
}
viewTmpl.Execute(w, p)
}
func (s *Server) reveal(w http.ResponseWriter, r *http.Request) {
p, err := s.Store.Take(r.Context(), r.PathValue("id"), s.Now())
if errors.Is(err, ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
secure(w)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
viewTmpl.Execute(w, p)
}
func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
p, err := s.Store.Peek(r.Context(), r.PathValue("id"), s.Now())
if err != nil || p.Burn {
http.NotFound(w, r) // burning pastes have no raw URL: a GET must not consume them
return
}
full, err := s.Store.Take(r.Context(), p.ID, s.Now())
if err != nil {
http.NotFound(w, r)
return
}
secure(w)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(full.Body))
}
func (s *Server) remove(w http.ResponseWriter, r *http.Request) {
ok, err := s.Store.Delete(r.Context(), r.PathValue("id"), r.Header.Get("X-Delete-Token"))
switch {
case err != nil:
http.Error(w, "internal error", http.StatusInternalServerError)
case !ok:
http.Error(w, "unknown paste or wrong token", http.StatusForbidden)
default:
w.WriteHeader(http.StatusNoContent)
}
}The view page goes through html/template, which escapes the body for the context it sits in (inside <pre>), so a pasted <script> arrives as visible text. The same template escapes the title in two different contexts (<title> and <h1>) correctly, which is why you do not concatenate HTML strings by hand. Defence in depth comes from the headers set in secure: nosniff stops a browser from guessing that a response is something other than its declared type, and the Content-Security-Policy of default-src 'none' forbids scripts, styles, frames and requests of any kind, so even a bug in escaping would have nothing to execute. Referrer-Policy: no-referrer stops the paste's URL leaking to any site its content links to, and no-store keeps proxies from retaining it.
Two details are about the burning behavior. The GET /p/{id} handler for a burning paste calls Peek, renders the interstitial, and never calls Take. Only POST /p/{id} consumes. And /raw returns 404 for burning pastes, since a raw URL has no click-through and would be consumed by the first fetch.
Running it
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"time"
)
func main() {
store, err := OpenStore("file::memory:")
if err != nil {
panic(err)
}
defer store.Close()
clock := time.Date(2026, 5, 1, 9, 0, 0, 0, time.UTC)
srv := &Server{Store: store, Now: func() time.Time { return clock }, Base: "https://paste.example"}
ts := httptest.NewServer(srv.Routes())
defer ts.Close()
do := func(method, path, body string, hdr ...string) (*http.Response, string) {
req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
for i := 0; i+1 < len(hdr); i += 2 {
req.Header.Set(hdr[i], hdr[i+1])
}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return res, string(b)
}
field := func(js, key string) string { // tiny JSON string extractor, enough for this demo
i := strings.Index(js, `"`+key+`":"`)
rest := js[i+len(key)+4:]
return rest[:strings.Index(rest, `"`)]
}
// 1. A hostile paste is shown as text.
_, js := do("POST", "/api/pastes", `{"title":"<b>hi</b>","body":"<script>alert(1)</script>\nsecond line","expires":"1h"}`)
id, tok := field(js, "id"), field(js, "delete_token")
res, page := do("GET", "/p/"+id, "")
fmt.Println("page contains the raw tag:", strings.Contains(page, "<script>"), "| contains the escaped form:", strings.Contains(page, "<script>"))
fmt.Println("CSP:", res.Header.Get("Content-Security-Policy"), "| nosniff:", res.Header.Get("X-Content-Type-Options"))
res, raw := do("GET", "/p/"+id+"/raw", "")
fmt.Printf("raw: %s, %q\n", res.Header.Get("Content-Type"), raw)
// 2. Burn after read: a link preview bot does a GET and the paste survives.
_, js = do("POST", "/api/pastes", `{"body":"the vault code is 4711","expires":"10m","burn":true}`)
bid := field(js, "id")
_, bot := do("GET", "/p/"+bid, "")
fmt.Println("bot GET shows the secret:", strings.Contains(bot, "4711"), "| shows the button:", strings.Contains(bot, "<button>"))
res, _ = do("GET", "/p/"+bid+"/raw", "")
fmt.Println("raw URL of a burning paste:", res.StatusCode)
_, first := do("POST", "/p/"+bid, "")
res2, second := do("POST", "/p/"+bid, "")
fmt.Println("first POST shows the secret:", strings.Contains(first, "4711"), "| second POST:", res2.StatusCode, strings.TrimSpace(second))
// 3. Deleting.
res, _ = do("DELETE", "/api/pastes/"+id, "", "X-Delete-Token", "wrong")
fmt.Println("delete with a wrong token:", res.StatusCode)
res, _ = do("DELETE", "/api/pastes/"+id, "", "X-Delete-Token", tok)
fmt.Println("delete with the right token:", res.StatusCode)
res, _ = do("GET", "/p/"+id, "")
fmt.Println("after deletion:", res.StatusCode)
// 4. Expiry and purge, with the clock moved.
do("POST", "/api/pastes", `{"body":"short lived","expires":"10m"}`)
do("POST", "/api/pastes", `{"body":"long lived","expires":"1w"}`)
clock = clock.Add(2 * time.Hour)
n, _ := store.Purge(context.Background(), clock)
fmt.Println("expired rows purged two hours later:", n)
// 5. Input limits.
for _, bad := range []string{`{"body":"","expires":"1h"}`, `{"body":"x","expires":"forever"}`, `{"body":"x","expires":"1h","title":"` + strings.Repeat("t", 101) + `"}`} {
res, _ := do("POST", "/api/pastes", bad)
fmt.Printf("rejected (%d): %.50s\n", res.StatusCode, bad)
}
res, _ = do("POST", "/api/pastes", `{"body":"`+strings.Repeat("a", 300<<10)+`","expires":"1h"}`)
fmt.Println("300 KB body:", res.StatusCode)
}page contains the raw tag: false | contains the escaped form: true
CSP: default-src 'none'; style-src 'none'; frame-ancestors 'none' | nosniff: nosniff
raw: text/plain; charset=utf-8, "<script>alert(1)</script>\nsecond line"
bot GET shows the secret: false | shows the button: true
raw URL of a burning paste: 404
first POST shows the secret: true | second POST: 404 404 page not found
delete with a wrong token: 403
delete with the right token: 204
after deletion: 404
expired rows purged two hours later: 1
rejected (400): {"body":"","expires":"1h"}
rejected (400): {"body":"x","expires":"forever"}
rejected (400): {"body":"x","expires":"1h","title":"tttttttttttttt
300 KB body: 400The first block is the one to read twice. The raw <script> tag does not appear in the page; its escaped form does. The response carries the locked-down CSP and nosniff. The raw endpoint returns the exact text as text/plain, which is safe to show in a browser because plain text is never interpreted.
In the burn block, the bot's GET receives the interstitial with a button and not the secret, and the raw URL of a burning paste is a 404. The first POST shows the secret and the second gets a 404, because the row is gone. Deleting needs the token: a wrong one is refused with 403 without revealing whether the paste exists, and the right one answers 204, after which the page is a 404. With the fake clock moved two hours forward, Purge removes the ten-minute paste and leaves the one-week paste. The limits at the end refuse an empty body, an unknown lifetime, an over-long title and a 300 KB body.
What the tests do not cover
Tests prove the store and the handlers. They do not prove the browser's behavior, which is where the security headers earn their keep. After deploying, load a paste containing a script tag in a real browser, with the developer console open, and confirm there is no execution and no CSP violation report for a resource you meant to allow. Fetch the raw URL and check that the browser displays text instead of offering a download or rendering markup. Those two checks take a minute and cover the failures that unit tests cannot see: a proxy that strips X-Content-Type-Options, a CDN that rewrites Content-Type, a template change that moves the body out of an escaped context.
Tests that matter
package main
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
func newStore(t *testing.T) *Store {
t.Helper()
s, err := OpenStore("file::memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestBurnAfterReadHasExactlyOneWinner(t *testing.T) {
s := newStore(t)
ctx := context.Background()
now := time.Now()
id, _, err := s.Create(ctx, Paste{Title: "t", Body: "secret", Burn: true, CreatedAt: now, ExpiresAt: now.Add(time.Hour)})
if err != nil {
t.Fatal(err)
}
var winners atomic.Int32
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if p, err := s.Take(ctx, id, now); err == nil && p.Body == "secret" {
winners.Add(1)
}
}()
}
wg.Wait()
if winners.Load() != 1 {
t.Fatalf("%d readers saw the secret, want exactly 1", winners.Load())
}
}
func TestPeekNeverRevealsABurningPaste(t *testing.T) {
s := newStore(t)
ctx := context.Background()
now := time.Now()
id, _, _ := s.Create(ctx, Paste{Title: "t", Body: "secret", Burn: true, CreatedAt: now, ExpiresAt: now.Add(time.Hour)})
p, err := s.Peek(ctx, id, now)
if err != nil || p.Body != "" || !p.Burn {
t.Fatalf("peek returned %+v, %v", p, err)
}
if _, err := s.Peek(ctx, id, now); err != nil {
t.Fatal("peeking must not consume the paste")
}
}
func TestExpiredPastesAreGoneEvenBeforeThePurge(t *testing.T) {
s := newStore(t)
ctx := context.Background()
now := time.Now()
id, _, _ := s.Create(ctx, Paste{Title: "t", Body: "x", CreatedAt: now, ExpiresAt: now.Add(time.Minute)})
if _, err := s.Take(ctx, id, now.Add(2*time.Minute)); err != ErrNotFound {
t.Fatalf("an expired paste was served: %v", err)
}
}
func TestDeleteTokenIsRequiredAndStoredHashed(t *testing.T) {
s := newStore(t)
ctx := context.Background()
now := time.Now()
id, tok, _ := s.Create(ctx, Paste{Title: "t", Body: "x", CreatedAt: now, ExpiresAt: now.Add(time.Hour)})
if ok, _ := s.Delete(ctx, id, id); ok {
t.Fatal("the paste ID must not work as a delete token")
}
var stored []byte
s.db.QueryRow(`SELECT delete_hash FROM pastes WHERE id = ?`, id).Scan(&stored)
if string(stored) == tok || len(stored) != 32 {
t.Fatalf("the database holds %d bytes; it must hold a SHA-256 of the token, never the token", len(stored))
}
if ok, _ := s.Delete(ctx, id, tok); !ok {
t.Fatal("the real token was refused")
}
}=== RUN TestBurnAfterReadHasExactlyOneWinner
--- PASS: TestBurnAfterReadHasExactlyOneWinner (0.02s)
=== RUN TestPeekNeverRevealsABurningPaste
--- PASS: TestPeekNeverRevealsABurningPaste (0.00s)
=== RUN TestExpiredPastesAreGoneEvenBeforeThePurge
--- PASS: TestExpiredPastesAreGoneEvenBeforeThePurge (0.00s)
=== RUN TestDeleteTokenIsRequiredAndStoredHashed
--- PASS: TestDeleteTokenIsRequiredAndStoredHashed (0.00s)
PASS
ok example.test/lab 1.405sThe first test is the reason Take is one statement. Twenty goroutines race for the same burning paste and exactly one reads the secret; with a separate SELECT and DELETE, several would. The second pins the rule that a peek never consumes and never carries the body. The third proves that expiry is enforced at read time, so a paste dies at its deadline even if the purge job is late or has never run, which is what you want from a privacy feature. The fourth checks two properties together: the ID cannot stand in for the token, and the database holds a 32-byte hash and not the token itself.
IDs, timing and the cost of retention
A few smaller decisions are worth a line each. A UUID v4 would work as the ID (122 random bits) but is longer and carries nothing extra; a ULID or UUID v7 would be a mistake here, because they start with a timestamp, and a paste address should reveal nothing about when it was made or what its neighbours are. Compare the two states "never existed" and "expired" carefully: this service answers both with the same 404, so a visitor cannot tell whether a guess hit a real paste that has since died. The tiny timing difference between a missing row and an expired one is not worth chasing at this scale.
Retention is also a cost. If the service stores a million pastes a day with an average of 4 KB each, a one-week maximum lifetime puts roughly 28 GB of text in the table at steady state, and a worst-case abuser pasting 256 KB blobs can multiply that by sixty. That is why the limits are explicit, why there is no "never expires" option, and why the total stored bytes belong on a dashboard. A per-client daily quota (counted by address, with the weaknesses that implies) is the usual next step when the first abuse arrives.
Putting it together as a service
The program that runs this is short. main opens the store, builds the Server, starts one goroutine that calls Purge every minute, and serves with an explicit http.Server that has ReadHeaderTimeout, a MaxHeaderBytes below the default and an IdleTimeout. The request body limit is already in the handler, so a client cannot make the server read more than 260 KB. On SIGTERM the program stops accepting connections, cancels the purge goroutine's context and closes the store last, in the reverse order of creation, as laid out in handler dependency injection and graceful shutdown. Configuration (the listen address, the database path, the base URL used to build links) comes from the environment through one loader, as in the configuration article.
The one operational detail specific to this service is logging. The access log must not contain paste IDs for burning pastes, and ideally no IDs at all, because a log line with /p/<id> is a copy of the capability to read that paste. Log the route pattern (/p/{id}), the status and the duration, which is enough to run the service, and keep the IDs out of every log, metric label and error report.
Hardening before this is public
Abuse. A service that stores arbitrary text from anonymous users will be used to host phishing text, malware commands and leaked credentials. Rate limit creation per address (rate limiting middleware), cap the total stored bytes, keep the lifetimes short, and provide an abuse contact and a way to take a paste down that does not need the delete token.
Syntax highlighting. Add it on the client, in a separate script served from your own origin, and relax the CSP to allow that one script. Do it with a nonce or a hash, not 'unsafe-inline'. Highlighting on the server is possible too, but then the output is HTML you generated and the escaping rule applies to every token.
Encryption. If you want the server to be unable to read pastes, encrypt in the browser with a key in the URL fragment, which is never sent to the server. That changes the product: search, abuse review and server-side previews stop working, and burn after read still needs the server to delete its ciphertext.
Cleanup. Run Purge on a timer, every minute or so, from one goroutine started in main and stopped with the server's context, as described in starting and stopping goroutines. Reads already ignore expired rows, so the timer is about disk, not correctness.
Storage. SQLite handles this load for a long time. Keep an eye on file size, enable WAL mode and run VACUUM occasionally, since deleted rows leave free pages. When you need several instances, the move to PostgreSQL is the same query set; DELETE ... RETURNING exists there too, so the burn semantics carry over unchanged.
The escaping behavior of html/template is documented in the package reference, and MDN's CSP guide explains each directive used in secure. The OWASP XSS prevention cheat sheet covers the output encoding rules in general.


