Not every viewer needs an account

A guest account (covered in [Inviting a client into one project](/blog/guest-access-one-project)) is the right tool when someone needs to interact — comment, add tasks, log in repeatedly over weeks. It's the wrong tool for an investor who wants to glance at progress once, or a stakeholder who needs a link to paste into a status email. Creating an account, a password, and an invite flow for a five-minute look is friction nobody wants on either side. WKFGo's answer is a separate, narrower mechanism: a revocable public link with no login at all.

The token is the entire security model — so it's treated like one

A ShareLink is a random 24-byte token, hex-encoded, generated with crypto/rand:

func newToken() string {
    b := make([]byte, 24)
    rand.Read(b)
    return hex.EncodeToString(b)
}

The code comment states the design intent directly: "treat it like a password-reset token (unguessable, revocable, optionally expiring)." That's the right mental model — a share link isn't "public but hard to find," it's a bearer credential with the same properties you'd demand of any other one. 24 bytes of cryptographic randomness (192 bits) makes guessing infeasible; it's not obscurity, it's the actual access control.

Not found vs. revoked — deliberately the same response

Resolving a token checks three things: does it exist, has it been explicitly revoked, and has it expired:

func (l *ShareLink) Live() bool {
    if l.RevokedAt != nil {
        return false
    }
    if l.ExpiresAt != nil && l.ExpiresAt.Before(time.Now()) {
        return false
    }
    return true
}

And the resolve function returns the exact same generic "not found" error whether the token never existed, expired, or was actively revoked:

if err := s.db.First(&link, "token = ?", token).Error; err != nil {
    return nil, errors.New("not found")
}
if !link.Live() {
    return nil, errors.New("not found")
}

This is the same principle behind the login endpoint returning one generic "invalid credentials" message instead of distinguishing "wrong password" from "no such user" — a revoked link responding differently than an expired one, or a never-issued token, would let someone probe which tokens used to work, which is information that shouldn't leak either.

What a guest actually sees

The board a share link exposes isn't the real project — it's a purpose-built, stripped-down projection:

type PublicTask struct {
    ID        uint       `json:"id"`
    Title     string     `json:"title"`
    Labels    []string   `json:"labels,omitempty"`
    Progress  float64    `json:"progress"`
    Priority  string     `json:"priority,omitempty"`
    DueDate   *time.Time `json:"dueDate,omitempty"`
    Assignees []string   `json:"assignees,omitempty"` // display names only
}

No task IDs beyond the task's own, no internal user IDs, no emails, no rates, no attachments, no links to anything else in the system. Assignees are display names, not accounts. This is a dedicated struct built for this one purpose — not the normal Task model with fields filtered out at serialization time, which is an easy place to accidentally leak a field a future schema change adds. Building the public shape as its own type means adding a sensitive field to the real Task model doesn't automatically expose it here; someone has to deliberately add it to PublicTask too.

Revocation is permanent, but the record isn't deleted

Revoking a link sets RevokedAt rather than deleting the row:

func (s *Service) Revoke(id uint) error {
    now := time.Now()
    return s.db.Model(&ShareLink{}).Where("id = ?", id).Update("revoked_at", now).Error
}

The token stops working the instant Live() evaluates false — but the row stays for the audit trail: who created it, when, how many times it was viewed, when it was last viewed. A revoked link isn't forgotten, it's just dead.

Common mistakes

Making a "public" link discoverable through pattern-guessing. Sequential IDs (/share/1, /share/2) or predictable tokens turn "share with whoever has the link" into "share with anyone who tries a few URLs" — the token needs to be the whole security boundary, which means it needs to actually be unguessable.

Returning different errors for expired vs. revoked vs. nonexistent tokens. It feels like better UX ("this link expired" vs. "this link doesn't exist") but it's also a way to confirm a token used to be valid, which is exactly the kind of information a revoked link shouldn't be able to leak.

Reusing the internal model for the public response. Filtering fields at serialization time works until someone adds a field to the internal model and forgets the filter list exists — a dedicated public-facing type fails safe by requiring an explicit opt-in for every field it exposes.

FAQ

Can I set a link to never expire?

Yes — ExpiresAt is nil by default, meaning the link stays live until someone explicitly revokes it.

Does viewing the board require any WKFGo cookies or session?

No — the token in the URL is the entire credential; there's no session state tied to the viewer at all.

Can a share link viewer see finance, documents, or knowledge base entries?

No — the public board payload only includes task title, labels, progress, priority, due date, and assignee names; nothing from finance, documents, or knowledge is part of that projection.

Summary

A public share link and a guest account solve different problems — one is a bearer token for a quick, anonymous look; the other is a scoped identity for ongoing collaboration. Building the public one as a genuinely narrow, purpose-built response — with a real random secret and a security model that doesn't leak state through different error messages — is what keeps "convenient to share" from becoming "convenient to guess."