TheRare Visuals

Security by design for a small team

Cookie sessions, tokens that can't be mistaken for each other, rate limits, security headers, private storage, and a paywall the browser can't bypass.

TheRare Engineering21 September 20267 min readIntermediate

What we're protecting

A photography platform holds some of the most personal things people own: their portraits, their newborn, their family. Before thinking about tools, we wrote down what could actually go wrong:

  • One customer sees another customer's photos. The worst case.
  • Someone gets finished photos or films without paying.
  • Someone guesses or floods their way into an account.
  • A shared gallery link reaches people it shouldn't.
  • A staff member does something beyond their role, by mistake or otherwise.

Every decision in this post maps back to one of these. Security for a small team isn't about buying tools; it's about a handful of design choices made early and applied everywhere.

Sessions live in httpOnly cookies

When a customer signs in, the API sets a cookie containing a signed token (a JWT):

Java
ResponseCookie.from("therare_session", token)
        .httpOnly(true)      // JavaScript can't read it
        .secure(cookieSecure) // HTTPS only (on in production)
        .sameSite("Lax")     // not sent on cross-site form posts
        .path("/")
        .build();

A common alternative is keeping the token in localStorage and adding it to each request. The problem: any JavaScript on the page can read localStorage, so one cross-site scripting bug, in your code or in a third-party script, can steal every session. An httpOnly cookie can't be read by JavaScript at all. The browser attaches it automatically, and the frontend sends requests with credentials: "include" and never touches the token.

Admin sessions use the same pattern with a separate cookie, a separate secret and a separate filter.

Tokens that can't be mistaken for each other

The platform issues several kinds of token: customer sessions, staff sessions, password-reset tokens, 24-hour tokens for gallery guests, and short-lived video-stream tokens. They're all JWTs, so a tempting shortcut is one signing secret and one validator.

The danger is token confusion: a token issued for one purpose being accepted for another. A guest's gallery token accepted as a customer login, or a password-reset token accepted as a session.

So every token carries a type claim, and each validator accepts only its own type:

Java
public boolean isTokenValid(String token) {
    try {
        Claims claims = extractAllClaims(token);
        boolean correctType = "CUSTOMER".equals(claims.get("type", String.class));
        return correctType && !isTokenExpired(claims);
    } catch (Exception ex) {
        return false;   // bad signature, malformed, anything: not valid
    }
}

Staff and customer tokens also use different secrets, so even a reused secret by mistake couldn't make them interchangeable. Guest tokens include the booking id, so a token for one gallery can't open another. The customer's token identifies them by an internal id rather than their email, because email is optional and can change.

A bug worth learning from: two cookies at once

The studio owner tests both sides of the platform, so their browser holds an admin cookie and a customer cookie. Every customer request started failing with "403 Forbidden".

Both authentication filters ran on every request. The admin filter ran first and signed the request in as the admin. The customer filter then saw "someone is already signed in" and skipped, so the customer request had no customer permissions.

The fix was to scope each filter to its own URL prefix: the admin filter only looks at /api/v1/admin/..., the customer filter only at /api/v1/customer/.... Security code has to handle the combinations real people produce, not just the clean cases in your head.

Rate limiting before anything else

Login, password reset, one-time codes and public forms are what attackers hammer. A rate-limiting filter runs first in the chain, before any authentication work, so an abusive request is rejected as cheaply as possible. Routes map to buckets with their own limits:

Java
new Rule("POST", "/api/v1/customer/auth/login",           "customer-login"),
new Rule("POST", "/api/v1/customer/auth/forgot-password", "password-reset"),
new Rule("POST", "/api/v1/email-otp/request",             "public-email-otp"),
new Rule("POST", "/api/v1/bookings/inquiry",              "public-form"),
// anything else under /api/ falls into a general bucket

Counts are kept in memory, keyed by bucket and client IP. That's correct because the API runs as a single instance, and the code says so plainly: if it ever scales out, counts must move to a shared store, or each instance would allow the full limit.

The paywall lives on the server

The rule that matters most for a studio's income: the browser is never trusted to enforce payment.

  • A locked photo has no clean URL in the API response at all. The customer gets a 1600-pixel watermarked copy made on the server.
  • A locked video has no clean stream. The stream token is created by the server and says "preview", and the preview encoding has the watermark burned into the pixels (Part 6).
  • RareAI Studio can only edit the customer's own unlocked photos, checked on the server, so it can't be used to produce a clean copy of an unpaid one.

If the only thing between a customer and the finished file is a CSS overlay or a hidden button, they already have the file.

Sharing galleries safely

Customers can share their gallery with family. A public link would be simple, and it would end up forwarded in a WhatsApp group of 200 people.

Instead, the customer lists the email addresses allowed in. A visitor to the share link enters their email, and only an allow-listed address gets a one-time code (or can sign in with Google). That produces a guest token that:

  • works for 24 hours, then the guest verifies again;
  • works for one booking only;
  • is re-checked on every page of photos, not just the first.

Removing an email from the list stops new verifications immediately, and existing tokens expire within a day.

  • Customer media lives in a private bucket. Nothing in it is public; every link is signed and expires.
  • Portfolio images live in a separate, public bucket. Mixing the two in one bucket isn't possible, and that's a good thing.
  • The server has no key file. On Cloud Run, it proves its identity through Google's metadata server, and signing goes through Google's IAM service. There's no private key on disk to leak. (It costs about 65 ms per signature, which is why Part 3 exists.)
  • Secrets like database passwords and API keys live in Secret Manager and arrive as environment variables. None are in the code.

Staff permissions

Staff roles grant specific permissions, and every admin endpoint checks one with @PreAuthorize("hasAuthority('MEDIA_WRITE')") (Part 4 explains the design). Irreversible actions, like hard-deleting a booking, are limited to the Owner. Important changes write an audit log entry saying who did what and when, so a mistake can be traced and explained.

Security headers on every page

The website sends a set of HTTP headers that tell the browser to be strict:

HeaderWhat it prevents
Content-Security-PolicyScripts from unexpected places running on our pages
X-Frame-Options: SAMEORIGINOther sites putting our pages in a frame to trick clicks
X-Content-Type-Options: nosniffThe browser guessing a file is a script when it isn't
Strict-Transport-SecurityAny connection over plain HTTP
Referrer-PolicyFull URLs, which can contain share codes, leaking to other sites
Permissions-PolicyCamera and microphone access we never use

The Content Security Policy lists exactly which outside origins may run scripts: Google Analytics, Google Maps, Razorpay's checkout and Google sign-in.

A lesson from building our 3D game: its models are compressed and decoded with WebAssembly, and in production the game failed with "Refused to create a WebAssembly object." The fix was adding 'wasm-unsafe-eval' to the policy. Despite the scary name, it only allows compiling WebAssembly, not JavaScript's eval(), which stays blocked. Read what a security keyword actually permits before you add it, or before you refuse to.

Keeping private pages out of search results

Search engines shouldn't index the API, the admin panel, customer accounts or shared galleries. The API subdomain serves a robots.txt that disallows everything and sends X-Robots-Tag: noindex. The site sends the same header for /admin, /account, /s/ and /share/. A robots.txt alone isn't enough: it asks crawlers not to visit, but a page linked from elsewhere can still be indexed without being crawled. The header is what keeps it out.

Sending less data to AI

RareAI Studio sends photos to Google's Gemini model for editing. The model receives the photo, the prompt, and the shoot type, city and date for context. It never receives the customer's name, email or phone number. It doesn't need them, so it doesn't get them. The same rule applies everywhere: don't send data to a third party just because it's convenient.

Takeaways

  • List what could go wrong for your product before choosing tools.
  • Put sessions in httpOnly, Secure, SameSite cookies, not localStorage.
  • Give every token a type and check it; use separate secrets per audience.
  • Rate-limit before authentication, and write down the assumptions behind your limiter.
  • Enforce payment and privacy on the server. The browser is a display, not a guard.
  • Keep storage private by default, with links that expire.
  • Send strict security headers, and understand each keyword in your policy.
  • Share less data with third parties than you could.

Part 8 is the last in the series: the production bugs that cost us a day each.

Found this useful?

Built something with what you learned here, or spotted a better way? Tell us. We work with freelance developers on Shootlo.