Security.The mechanisms, and the gaps.
Most security pages are a wall of badges and a paragraph about how seriously we take your trust. This one names the code. Every claim in the first half points at something implemented today. The second half is what is missing - written down here rather than discovered by you later.
If a sentence on this page does not name the thing that implements it, it is not a security claim, it is a mood.
Unauthenticated and cross-tenant requests get a not-found. Probing cannot confirm that a route, a job or an account exists.
No SOC 2, no ISO 27001, no BAA, no pen test, no two-factor. That is section 09, on this page rather than left off it.
Never stored, only derived
A password you type here is turned into a scrypt key and thrown away. What sits in the database is a salt and a derived key, which is not a password and cannot be turned back into one.
scrypt, per-user salt
16 random bytes of salt per account, a 64-byte derived key, stored as salt:key. Two people with the same password get different rows.
Constant-time compare
Verification derives the key again and compares with timingSafeEqual, so how long a wrong password takes tells an attacker nothing about how close it was.
A missing account costs the same as a wrong password
Sign-in runs the scrypt work even when no such user exists, against a placeholder hash, and returns one message for both cases. Response timing cannot be used to enumerate who has an account.
Sign-up refuses without naming the reason
An address that already exists is turned away with "that email cannot be used", never "that email is taken". Weaker than the sign-in path, and we will not dress it up: someone determined can still learn an account exists by trying to create it. Sign-in is where it matters, and sign-in gives nothing away.
Ten characters, and something you do not reuse
Enforced at sign-up. That is the whole rule - no composition theatre, no forced rotation.
Length is the only requirement, there is no second factor, and nothing throttles a guess. scrypt makes every attempt cost real CPU, which is a price rather than a lock. Until two-factor and rate limiting ship, a long unique password is the entire defence on your account.
The cookie is a pointer, not a claim
A signed-in browser holds 32 random bytes and nothing else. No encoded user id, no expiry it could edit, no signature to forge. The session itself lives in our database, which is what makes signing out mean something.
Random token, hashed at rest
The cookie value is randomBytes(32); the row stores only its sha256. A leaked database dump does not hand anyone a working session.
httpOnly, sameSite lax, secure in production
Script on the page cannot read it, a cross-site form post cannot ride it, and it does not travel over plain http in production.
Sign out actually revokes
The row is deleted. Compare that with a self-contained token, where signing out means asking a cookie you can no longer see to please stop being valid.
Expiry is enforced on read
Sessions run 30 days. One found past its expiry is deleted on the spot and treated as absent, not merely refused.
Another account's id looks like nothing
Every read and every write is scoped by the calling project, in the query itself rather than in a check after the fact. There is no code path that loads a row first and asks who owns it second.
Scoped at the where clause
A job is fetched with { id, projectId }. Guessing a valid id from another account returns exactly what a made-up id returns.
404, not 403
Unauthenticated and cross-tenant requests get a not-found. A 403 confirms the thing exists, which is half of what an attacker wanted.
Storage keys cannot walk out of the root
Keys are pattern-checked, then the resolved absolute path must still sit inside the storage directory. The pattern check is the one that gets outsmarted, so it is not the one we rely on.
An uploaded source has to prove it is ours
The browser uploads a file, gets a URL back and hands that URL to the job. It arrives from the client, so it is treated as hostile: same origin, under /f/, and a key that survives validation, or it is not an upload.
The studio never touches an API key
Browser uploads authenticate with the session cookie. The project key stays server-side and never reaches client JavaScript.
Isolation ends at the row, not at the file. A rendered clip is served from an unguessable key under /f/ with no session check, because that is how a video tag plays it and how your webhook consumer downloads it. Anyone holding the link holds the file. Treat a clip URL as the secret it is - short-lived signed URLs are on the list at the bottom of this page.
Two URLs, checked twice
You give us a source to fetch and, if you are a partner, a callback to POST. Both are fetched by our own infrastructure, which sits inside a private network. Without a host check, either one is a port scanner pointed at us, using error text and response timing as the readout.
Blocked before the job is queued
Loopback, RFC1918, carrier-grade NAT, link-local (which is where cloud metadata lives at 169.254.169.254), multicast and reserved space. Plus localhost, metadata.google.internal, and anything ending .local, .internal or .home.arpa.
http and https only, no embedded credentials
file://, gopher:// and data: are refused outright, and a URL carrying user:pass is refused before anything resolves it.
A bare name is an intranet name
A hostname with no dot in it means something inside the network, not something on the internet. Refused.
Re-resolved at the worker, immediately before connecting
The control-plane check catches literals. The worker resolves the name again at connect time and every answer must be public - a name with one private A record is still a way in.
Redirects are checked too
A public URL that 302s to 169.254.169.254 is the oldest version of this attack. Each hop goes through the same check.
DNS rebinding between that final lookup and the connect itself is a residual risk. Only a client that resolves and then connects by IP removes it, and ours does not do that yet.
Signed, so you can refuse the rest
When a job finishes we POST to your endpoint. Your endpoint is on the internet, so anyone can POST to it. The signature is what separates us from anyone.
HMAC-SHA256 over the exact body
X-Cutlist-Signature: sha256=... computed with your project's own secret. Compute it over the raw body you received and compare in constant time. If it does not match, it was not us.
A delivery id you can deduplicate on
X-Cutlist-Delivery carries the job id, so a repeat delivery is recognisable rather than a second copy of the work.
https, and never aimed back at us
In production a callback URL has to be https, and it runs through the same host guard a source does. You cannot point a callback at our internals and read the answer off the delivery.
Redirects are not followed
redirect: manual. A 3xx from a webhook endpoint is a misconfiguration, not a delivery target.
A broken endpoint cannot fail a finished job
Delivery has a 10-second timeout and never throws. The render already succeeded, and GET /api/v1/jobs/:id returns the identical payload whenever you ask for it.
There is no automatic retry and no delivery log yet. If your endpoint was down, poll the job - the payload is the same one the webhook would have carried.
Shown once, stored as a hash
A partner project gets one key at creation. We keep the sha256 of it and a short non-secret prefix for display, which means we cannot tell you what your key is - only whether the one you just sent matches.
clk_live_ plus 24 random bytes
Generated with randomBytes and base64url-encoded. The prefix, clk_live_ and four characters, is what the interface shows you so you can tell two keys apart.
Authentication is an indexed lookup
The bearer token is hashed and looked up by hash. Constant work, no scan, no compare loop over stored secrets.
The worker token is compared on digests
Both sides are hashed before timingSafeEqual, so the compared buffers are always the same length. A raw compare would need a length check first, and that check leaks the expected token's length through timing.
A key opens the API and nothing else
A partner project is a key with no person behind it. Presenting a key authenticates a project against /api/v1 - it never mints a session, and there is no studio to walk into.
One key per project, no scopes, and no rotation without us doing it by hand. If a key is exposed, mail us and we will cut a new one.
Everything has a clock on it
Clips are working output, not an archive. Holding your footage forever would cost us money and make us a target, so nothing here is open-ended. The numbers below are the ones the code enforces.
Your account
An email address, a name, and a scrypt hash. That is the whole record of you.
Source uploads
Removed within 2 days of a finished render. Once the cut exists there is no reason to keep the original.
Clips, posters and SRT files
30 days, then swept. The studio counts each clip down to its expiry so nothing vanishes unannounced.
Transcripts and word timings
Stored on the clip row, because that is what makes text editing work. They go when the clip goes.
Sessions
One row per signed-in browser, 30 days, deleted the moment you sign out.
Credit ledger
Append-only, every movement recorded with the job that caused it. A balance you cannot explain is a support ticket.
Storage total
100 GB per account, shared across every project you own.
What leaves our infrastructure
Sampled frames, transcript text and on-screen text go to the model that reviews them. Your clips go to your webhook if you configured one. Nothing goes anywhere else, and nothing is sold.
The credit ledger is append-only on purpose, so deleting a job still leaves the line saying it was billed. That is the one record that outlives the work it describes.
Delete means the file, not the row
Plenty of products mark a record deleted and leave the media sitting in a bucket. The order matters here: files first, quota returned, rows last - because once the clip rows are gone we no longer know what to delete.
Delete a job
From the studio. Every video, poster and SRT is unlinked from disk, your storage allowance is given back, and the rows drop after. Clips cascade with the job.
Cancel a running job
The held credits are refunded and the job stops. A cancel is not a silent charge.
The expiry sweep
Runs on a schedule, claims each row by deleting it before touching its files, and is safe to run twice at once. It is authenticated with a secret, and a wrong secret gets the same 404 as everything else here.
The whole account
Mail us and we remove the account, its projects, jobs, clips and files. We answer within two working days and tell you when it is done. There is no self-serve button yet, which is why it is on the list below.
What we do not have
Everything above is code you could point at. Here is the other half. Cutlist is a young product and none of the following exists. If your procurement process needs any of it, we are not your vendor yet - and you should know that now rather than three weeks into a questionnaire.
SOC 2
No Type I, no Type II. We are not audited, and an unaudited badge is just a picture.
ISO 27001
Not certified. Not in progress.
HIPAA and a BAA
We will not sign one. Do not put protected health information through this product.
Rate limiting
None. Sign-in and the API take requests as fast as you can send them. scrypt puts a real CPU price on every password guess, which slows an attacker down without ever stopping one.
Two-factor authentication
A password and a server-side session, and that is all there is between someone and your account.
A penetration test report
None yet. The code has been read carefully by the people who wrote it, which is a different and much weaker thing.
A bug bounty programme
No payouts. The reporting route below is real and answered; the cheque does not exist.
SSO, roles and audit logs
No SAML, no per-seat trail, no shared workspaces. One account, one set of projects.
Encryption at rest
Artifacts sit on disk inside our infrastructure. We do not run application-level encryption over them, and claiming otherwise would be the easiest lie on this page.
A wall of certifications nobody checked tells you only what a vendor is willing to imply. This list will get shorter. It will not get quieter.
Reporting a vulnerability
One address, read by a person who can fix the thing. No form, no portal, no triage queue that eats your report and returns a reference number.
hello@heckraiser.com with “security” in the subject. Include the URL or endpoint, what you sent, and what came back. A short proof of concept is worth more than a scanner export.
We acknowledge within two working days, reply as a person rather than a template, tell you what we found and tell you when it is fixed. Credit in the changelog if you want it, silence if you do not.
Research in good faith against your own account is welcome and we will not come after you for it. Stop as soon as you have proof, and tell us before you tell anyone else.
Do not touch other people’s accounts or footage, do not run denial of service, do not social-engineer anyone. And to say it before you spend the evening: we do not pay bounties today.
What we are working on next
Two-factor sign-in
The largest single gap on this page, and the first one closing.
Rate limiting and lockout
So a password guess costs an attacker time as well as CPU.
Signed artifact URLs
Short-lived, per object, so a clip link stops working when it should.
Self-serve account deletion
A button, so the deletion path above stops being an email.
Webhook retry and a delivery log
Backoff on failure, and a record you can look at.
Multiple keys and rotation
Issue a second key, move traffic, revoke the first. No downtime.
An independent penetration test
Before we take anyone's enterprise money, not after.
No dates. A date on this list would be the invented part.
You read the gaps and you are still here. Upload half an hour of footage on the trial and judge the cut the same way you just judged the page.
Found a hole? hello@heckraiser.com