When Tenants Can See One Another: From Signed Artifacts to Authenticated State Transitions (Part 1)
TL;DR We encrypted every competition submission and required a valid team signature. Yet another GitHub user could copy an unchanged public bundle and spend the original teamβs daily quota. This postmortem explains w
TL;DR
We encrypted every competition submission and required a valid team signature. Yet another GitHub user could copy an unchanged public bundle and spend the original teamβs daily quota.
This postmortem explains why the cryptography worked, why the system still failed, and why secure public workflows must authenticate state transitions rather than artifacts alone.
Postmortem status: The workshop has ended, and the affected scoring workflows have been manually disabled. There is no active scoring endpoint associated with this design.
How a valid digital signature became a transferable authorization token in a public GitHub competition
We encrypted the submission.
We signed its manifest.
We verified the signature against a team certificate.
The signature was valid.
And yet, another GitHub user could copy the complete submission into a new pull request and make the original team spend another scoring attempt.
No private key had to be stolen.
No signature had to be forged.
No ciphertext had to be decrypted.
The cryptography did exactly what we asked it to do.
The problem was that we had asked the wrong security question.
A GitHub-native data-cleaning tournament
In May 2026, we ran a data-cleaning tournament as part of a Python workshop.
Each team wrote a Python cleaner that transformed public training and test feature files. The official scorer then trained a fixed model, evaluated the cleaned test data against hidden labels, and recorded the result on a shared leaderboard.
We wanted several properties:
- teams should not be able to inspect the hidden test labels;
- organizers should be able to execute each cleaner consistently;
- teams should not need to reveal their cleaner source code publicly;
- only an authorized team should be able to submit under its assigned team identity;
- each team should receive only a limited number of scored attempts per day.
GitHub already provided most of the surrounding infrastructure:
- participant identity;
- forks and pull requests;
- workflow events;
- hosted runners;
- workflow artifacts;
- an auditable repository history;
- GitHub Pages for the leaderboard.
So we used pull requests as the submission transport.
The system looked approximately like this:
Team writes clean.py
β
β encrypt to organizer certificate
βΌ
submission/clean.py.cms
β
β SHA-256
βΌ
submission/manifest.json
β
β sign with team private key
βΌ
submission/manifest.sig
β
β public pull request
βΌ
GitHub Actions
β
βββ verify manifest signature
βββ decrypt cleaner
βββ execute cleaner
βββ encrypt cleaned outputs
βββ pass outputs to trusted scorer
β
βββ decrypt hidden labels
βββ train fixed model
βββ calculate score
βββ update leaderboard
An official submission contained the encrypted cleaner, the manifest, its detached signature, and the dependency files required to execute the cleaner.
The repositoryβs helper script encrypted clean.py, calculated the ciphertextβs SHA-256 digest, placed that digest in the manifest, and signed the manifest using the teamβs private key.
At first glance, this appeared to provide a clean chain of trust.
What exactly did the signature authenticate?
A simplified manifest looked like this:
{
"team_id": "team-06",
"code_path": "submission/clean.py.cms",
"code_sha256": "b7a4...64 hexadecimal characters..."
}
The verification script performed several sensible checks:
- validate the format of
team_id; - require the code path to be
submission/clean.py.cms; - calculate the actual SHA-256 digest of the encrypted cleaner;
- require that digest to match
code_sha256; - look up the teamβs certificate in an allowlist;
- validate that certificate against the tournament CA;
- verify the detached signature over the manifest.
The workflow then installed the submitted dependencies, decrypted the cleaner, ran it against the tournament data, validated its outputs, and uploaded encrypted scoring artifacts.
The verifier was therefore able to establish:
The holder of Team 06βs private key signed this exact manifest, and the encrypted cleaner currently present has the digest recorded in that manifest.
That statement was true.
But it was narrower than the statement we implicitly relied upon:
Team 06 is authorizing this particular GitHub pull request as a new scoring attempt.
Those are not the same statement.
The missing subject: who is presenting the signed artifact?
A digital signature binds a key to some bytes.
It does not automatically bind those bytes to every context in which they might later appear.
Our signed manifest included:
- team identity
- encrypted-code path
- encrypted-code digest
It did not include, or otherwise bind itself to:
- the current GitHub PR author
- the authorβs immutable GitHub user ID
- the source repository
- the pull request number
- the current head commit
- a unique logical attempt ID
- an expiry time
- a one-time challenge
More importantly, the workflow did not independently compare the current PR actor or source repository with an identity authorized to submit for the signed team_id.
The workflow took the submitted files from the pull requestβs head repository and verified the team manifest, but the verification decision remained entirely about the artifact. It did not ask whether the GitHub user presenting that artifact was entitled to act for the team named inside it.
In effect, our valid signed bundle had become a bearer artifact:
Anyone possessing an unchanged copy could present it, while the system continued attributing the action to the original signing team.
The signature was public evidence of past authorization.
We had accidentally treated it as reusable authority for a new action.
The Threat Model (Replay Attack)
Consider two participants:
Alice β a legitimate member of Team A
Charlie β a different GitHub user
Alice prepares a valid submission:
submission/clean.py.cms
submission/manifest.json
submission/manifest.sig
Her manifest says:
{
"team_id": "team-a",
"code_path": "submission/clean.py.cms",
"code_sha256": "H"
}
Team Aβs private key signs that manifest.
Alice opens a pull request.
The submission is validly scored.
Because the pull request belongs to a public repository, Charlie can obtain the submitted encrypted cleaner, manifest, signature, and associated public dependency files.
Charlie does not edit them.
He does not need to understand the cleaner.
He simply places the same files in another branch or fork and opens another pull request.
The verifier now observes:
Manifest signature: valid
Team certificate: valid
Ciphertext SHA-256: matches
Team ID: team-a
Every cryptographic check succeeds, because the signed artifact has not changed.
The trusted scorer subsequently obtains team_id from that manifest. It counts the dayβs previous leaderboard submissions carrying the same team ID, checks the configured daily limit, and appends each accepted result as another submission for that team.
Therefore Charlieβs replay is charged to Team A.
Repeated replays can consume Team Aβs available daily attempts.
After that, Alice may be unable to submit another scored attempt that day.
The problem was not duplicate content
This distinction matters.
It may be perfectly legitimate for Alice to submit exactly the same cleaner twice.
Perhaps she wants to confirm reproducibility. Perhaps she accidentally changed something elsewhere. Perhaps competition policy simply says that every intentional submission consumes one attempt, even when the code is identical.
The desired policy may be:
Alice intentionally submits content H as attempt A-001
β accepted and charged to Aliceβs team
Alice intentionally submits content H again as attempt A-002
β accepted and charged again to Aliceβs team
The vulnerability was not that identical content could ever be processed more than once.
The vulnerability was:
Charlie could make that decision on Aliceβs behalf.
A secure redesign therefore should not necessarily deduplicate by file hash.
It should distinguish between:
- content identity β whether two submissions contain the same files;
- attempt identity β whether they represent the same logical submission attempt;
- principal identity β who is requesting the attempt;
- team attribution β which team the authenticated principal belongs to. An explicit, signed attempt identifier can allow Alice to resubmit the same content intentionally, while still making workflow retries idempotent.
Binding the request to Aliceβs authenticated GitHub identity prevents Charlie from replaying Aliceβs signed attempt.
Why stronger encryption would not have fixed it
It is tempting to look at a replay involving an encrypted file and conclude that the encryption mechanism needs to be strengthened.
But confidentiality was not the failed property.
Charlie never learned the contents of the encrypted cleaner.
Changing the encryption algorithm would not stop him from copying the exact ciphertext.
Authenticated encryption can detect modification. It does not, by itself, prevent an unchanged valid ciphertext from being presented again.
Likewise, the digital signature correctly prevented Charlie from changing:
team-a
into:
team-charlie
Any such modification would invalidate Team Aβs signature.
But Charlie did not need to modify anything.
Replay attacks live in the gap between:
This message is authentic.
and:
This message is authorized here, now, by the actor currently presenting it.
Cryptographic authenticity is one input to authorization. It is not the complete authorization decision.
Public multi-tenancy changes the threat model
In many multi-tenant systems, tenants are separated by confidentiality boundaries.
Tenant A cannot inspect Tenant Bβs records, credentials, or requests.
Our environment was different.
Every team interacted through the same public repository. Pull requests were visible. Signed artifacts were visible. Encrypted submissions were visible. Tenants could inspect and copy one anotherβs transport-level records.
This was not an accidental data leak. Public visibility was part of the platform model.
That leads to a useful design rule:
In a publicly observable multi-tenant system, every public artifact must be assumed copyable and replayable.
Security cannot depend on another tenant being unable to obtain:
a manifest
a public key
a signature
an encrypted bundle
a previous request
Instead, each proposed action must be evaluated using both:
the submitted artifact
+
the authenticated context in which it is being presented
For GitHub, that context may include the eventβs immutable actor ID, repository ID, pull request metadata, a protected identity registry, and the current authoritative system state.
The root cause in one sentence
The root cause was:
We treated possession of a valid signed artifact as authorization to initiate a new state transition.
The signature answered:
Who signed these bytes?
The scorer needed answers to several additional questions:
Who is presenting them now?
Is that GitHub identity registered to the signing key?
Does that identity belong to the claimed team?
Is this a new intentional attempt or a retry of an existing attempt?
Is the requested transition allowed under the teamβs current quota?
Which authoritative state should be updated?
Those questions belong to an authorization and state-management layer, not to the signature algorithm alone.
Operational response
By the time of this publication, the workshop had ended and the relevant cleaner and scoring workflows had been manually disabled. GitHubβs workflow pages show the affected workflows as disabled, so the historical design is no longer an active scoring surface.
We are publishing this as a postmortem rather than as a vulnerability in GitHub.
GitHub Actions behaved according to its configuration.
The design error existed in our application-level trust model:
public pull requests
+
reusable signed artifacts
+
team-based quotas
+
no binding between the current actor and the signed team identity
From signed artifacts to authenticated state transitions
The redesigned system begins with a different abstraction.
A pull request does not directly become a registration or submission.
Instead, it is an untrusted proposal for a state transition.
Public PR artifact
+
Authenticated GitHub actor
+
Registered participant key
+
Immutable team membership
+
Current quota and attempt state
β
Policy verification
β
Authorized state transition
The important question is no longer only:
Is this signature valid?
It becomes:
Is this authenticated actor authorized to request this exact transition in the systemβs current state?
The next part of this series will examine the first half of that redesign:
- self-service participant onboarding;
- binding a GitHub identity to a participant public key;
- allowing organizers to accept previously unknown participants;
- requiring every member to consent to the same complete team manifest;
- making team membership immutable for the duration of the competition;
- storing the resulting identity and team records as auditable authoritative state.
The final part will return to submissions:
- actor-bound submission manifests;
- explicit attempt identifiers;
- intentional resubmission of identical content;
- cross-principal replay prevention;
- quota reservation;
- concurrency;
- and scoring attribution.
Closing thought
The most important lesson was not about a particular encryption mode or signature algorithm.
It was this:
A valid signature proves that a key endorsed some bytes. It does not prove that the person presenting those bytes now is entitled to trigger a new action.
In a system where every tenant can see every other tenantβs artifacts, signed objects should be treated as public claimsβnot as bearer authority.
Authentication, authorization, freshness, identity binding, and state transition policy must still happen at the moment the artifact is used.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.


