Diagnostic Slack
files made public with a link that works without a Slack login
Nothing errored, and nothing is going to. Somewhere in the app's history a developer needed an image URL that Block Kit could actually fetch, called files.sharedPublicURL, and it worked. Every file that call has been made against since — customer exports, database dumps, screenshots with a token still on screen — is readable by anyone holding the link, with no Slack account, no workspace membership and no expiry. The flag is on each file, and files.list will hand you all of them.
Page files.list?count=200&types=all with files:read and report every file where public_url_shared is true. That field means one thing only: a permalink_public exists and serves the file to unauthenticated requests.
Do not confuse it with is_public, which merely means the file is shared into a public channel and still requires a Slack login. Reporting those as exposures buries the real finding. The repair is files.revokePublicURL, which needs files:write — this script prints the call rather than making it.
The problem in plain words
This is a data-exposure finding, not a bug report, and it deserves to be read that way. A public Slack file URL is an unauthenticated, unexpiring, unlogged capability. It is not covered by channel permissions, it survives the file being unshared from every channel, it survives the message that carried it being deleted, and it does not stop working when the person who created it leaves the company. There is no access log to check afterwards to find out whether anyone used it.
It also has essentially no false positives. Unlike most audit findings, there is nothing to interpret: either public_url_shared is true, in which case a URL exists that a stranger can fetch, or it is false. The only judgement in the whole check is deciding which of those files should not have been public, and that is a question for a human with context, which is exactly why the script reports and does not revoke.
Why it happens
Block Kit forced somebody's hand. An image_url in a Block Kit block has to be fetchable by Slack's own image proxy without credentials, and url_private is not. The documented workaround people find is files.sharedPublicURL, and it is a single call that makes the image render immediately. Nobody comes back to it.
The flag is permanent until explicitly revoked. There is no TTL on permalink_public. The only way it stops working is files.revokePublicURL, or the file being deleted. Retention policies will eventually delete old files, which means the exposure quietly ends years later for reasons unrelated to anyone noticing it.
The URL is guessable enough to matter. A public Slack file link is a long path, not a signed URL with a secret, and it is routinely pasted into tickets, wikis, emails and third-party tools that index what they are given. Treat it as published, not as obscure.
Two flags look alike and mean completely different things. is_public is about channel visibility inside the workspace. public_url_shared is about the internet. A check that conflates them reports every screenshot ever posted in #general and gets ignored.
The fix, as a flow
The script reads two flags per file and keeps them apart, because is_public means members of a public channel can see it and public_url_shared means anybody on the internet can.
How to fix it
Page files.list rather than reading the first page
files.list?count=200&types=all returns a paging object with pages. The default page size is small and the exposures are usually old, so a first-page-only read is the version of this check that reports zero findings on a workspace that has hundreds.
Split the two visibility flags
Keep public_url_shared and is_public in separate buckets. The first is the finding. The second is normal workspace behaviour and belongs in the run as context, not as an alert.
Surface the files nobody can see from inside Slack
A file with public_url_shared: true whose channels, groups and ims arrays are all empty is the worst case in the list: it is unreachable through the Slack UI, so no member will ever stumble on it and report it, while the public URL keeps serving. Report it separately.
Confirm one link empirically
Fetch one permalink_public with no Authorization header at all. A 200 carrying the real bytes is proof; a redirect to a Slack sign-in page means the link is already dead. One confirmation is enough to establish that the flag means what the docs say it means in your workspace.
Revoke, then remove the reason it was needed
files.revokePublicURL?file=F... per file, which needs files:write and is why this script prints the list instead of acting on it. Then fix the cause: host Block Kit images on your own infrastructure, or upload the file to Slack and reference it in the message so channel permissions apply. Ask an admin to disable public file sharing workspace-wide if the app has no legitimate need for it.
How to check it worked
Re-run after the revocations. The exposed count should be zero, and the files that are merely in public channels should still be listed as such.
python3 slack_public_files_audit.py --max-files 5000
# 3184 file(s), 0 exposed, 0 exposed and unreachable in Slack
The full code
One paginated GET against files.list, with files:read and nothing else — the revocation needs files:write, which this script deliberately does not use. The pure function is the per-file verdict, and it earns its place by keeping is_public and public_url_shared apart: the whole value of this check is that it reports only the files a stranger can actually fetch.
"""Report Slack files that are readable without a Slack login.
Read only. One paginated GET and no writes: a bot token with files:read is
enough, and the revocation that repairs this needs files:write, which this
script deliberately does not use. The repair is printed for a human to run.
"""
import argparse
import datetime as dt
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_public_files_audit")
API = "https://slack.com/api"
def verdict(f):
"""Classify one file by which visibility flag is set. Pure, so the rule can
be tested without a network.
The distinction this function exists to protect: `is_public` means the file
is shared into a public channel and a Slack login is still required, while
`public_url_shared` means a permalink_public exists that serves the bytes to
anyone on the internet. Only the second is a data exposure. Conflating them
reports every screenshot ever posted in #general and buries the finding.
Returns (state, detail).
"""
if f.get("is_external"):
return ("external",
"hosted outside Slack, so Slack's flags do not govern who can "
"read it. Check the origin instead.")
public_link = bool(f.get("public_url_shared"))
shared = bool(f.get("channels") or f.get("groups") or f.get("ims"))
if public_link and not shared:
return ("exposed-orphan",
"public URL live and the file is in no channel, group or DM. "
"Nobody inside Slack can see it to report it, and the link "
"still serves.")
if public_link:
return ("exposed",
"public URL live. Readable by anyone holding the link: no "
"login, no expiry, no access log.")
if f.get("is_public"):
return ("workspace-visible",
"shared into a public channel. Visible to members, still gated "
"behind a Slack login. Not an exposure.")
return ("private", "no public URL, not in a public channel")
def human_size(n):
n = float(n or 0)
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return "%.0f%s" % (n, unit)
n /= 1024
def call(session, method, **params):
"""One Web API read. Slack answers almost every failure with HTTP 200 and
puts the error in the body, so the body is what gets asserted on."""
r = session.get("%s/%s" % (API, method), params=params, timeout=30)
r.raise_for_status()
body = r.json()
if not body.get("ok"):
raise SystemExit("%s: %s (needed=%s provided=%s)"
% (method, body.get("error"), body.get("needed"),
body.get("provided")))
return body
def list_files(session, limit):
"""Page files.list. This resource uses page numbers rather than cursors, and
a first-page-only read is how this check reports zero findings on a
workspace with hundreds: the exposures are usually old."""
out, page = [], 1
while len(out) < limit:
body = call(session, "files.list", count=200, page=page, types="all")
out.extend(body.get("files", []))
pages = int((body.get("paging") or {}).get("pages") or 1)
if page >= pages:
break
page += 1
return out[:limit]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-files", type=int, default=5000,
help="stop paging after this many files")
ap.add_argument("--show-workspace-visible", action="store_true",
help="also list files that are in public channels but still "
"require a Slack login")
args = ap.parse_args()
token = os.environ.get("SLACK_BOT_TOKEN")
if not token:
log.error("set SLACK_BOT_TOKEN (a bot token with files:read is enough)")
return 2
session = requests.Session()
session.headers.update({"Authorization": "Bearer " + token})
me = call(session, "auth.test")
log.info("authenticated as %s in %s", me.get("user"), me.get("team"))
files = list_files(session, args.max_files)
if not files:
log.info("no files visible to this token")
return 0
exposed = orphaned = 0
# Newest and largest first: a recent export is a more urgent conversation
# than a four-year-old screenshot.
for f in sorted(files, key=lambda x: (int(x.get("created") or 0),
int(x.get("size") or 0)), reverse=True):
state, detail = verdict(f)
if state == "private":
continue
if state == "workspace-visible" and not args.show_workspace_visible:
continue
created = dt.datetime.utcfromtimestamp(int(f.get("created") or 0)).date()
line = "%-17s %s %s %s %s" % (state, f.get("id"), created,
human_size(f.get("size")),
(f.get("name") or "")[:48])
if state in ("exposed", "exposed-orphan"):
exposed += 1
orphaned += 1 if state == "exposed-orphan" else 0
log.warning(line)
log.warning(" %s", detail)
log.warning(" public link: %s", f.get("permalink_public"))
log.warning(" repair: files.revokePublicURL?file=%s (needs "
"files:write, which this script does not hold)",
f.get("id"))
else:
log.info("%s %s", line, detail)
log.info("%d file(s), %d exposed, %d exposed and unreachable in Slack",
len(files), exposed, orphaned)
if exposed:
log.warning("stop minting public URLs for Block Kit images: host them "
"yourself, or reference the uploaded file so channel "
"permissions apply. An admin can disable public file "
"sharing workspace-wide.")
return 1 if exposed else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Slack files that are readable without a Slack login.
*
* Read only. One paginated GET and no writes: a bot token with files:read is
* enough, and the revocation that repairs this needs files:write, which this
* script deliberately does not use. The repair is printed for a human to run.
*/
const API = 'https://slack.com/api';
/**
* Classify one file by which visibility flag is set. Pure, so the rule can be
* tested without a network.
*
* The distinction this function exists to protect: `is_public` means the file
* is shared into a public channel and a Slack login is still required, while
* `public_url_shared` means a permalink_public exists that serves the bytes to
* anyone on the internet. Only the second is a data exposure.
*/
export function verdict(f) {
if (f.is_external) {
return ['external',
"hosted outside Slack, so Slack's flags do not govern who can read it. " +
'Check the origin instead.'];
}
const publicLink = Boolean(f.public_url_shared);
const shared = Boolean((f.channels ?? []).length || (f.groups ?? []).length ||
(f.ims ?? []).length);
if (publicLink && !shared) {
return ['exposed-orphan',
'public URL live and the file is in no channel, group or DM. Nobody ' +
'inside Slack can see it to report it, and the link still serves.'];
}
if (publicLink) {
return ['exposed',
'public URL live. Readable by anyone holding the link: no login, no ' +
'expiry, no access log.'];
}
if (f.is_public) {
return ['workspace-visible',
'shared into a public channel. Visible to members, still gated behind a ' +
'Slack login. Not an exposure.'];
}
return ['private', 'no public URL, not in a public channel'];
}
export function humanSize(bytes) {
let n = Number(bytes ?? 0);
for (const unit of ['B', 'KB', 'MB', 'GB']) {
if (n < 1024 || unit === 'GB') return `${n.toFixed(0)}${unit}`;
n /= 1024;
}
return `${n}B`;
}
async function call(token, method, params = {}) {
const url = new URL(`${API}/${method}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`${res.status} from ${method}`);
const body = await res.json();
// Slack answers almost every failure with HTTP 200 and puts the error in the
// body, so the body is what gets asserted on.
if (!body.ok) {
throw new Error(`${method}: ${body.error} (needed=${body.needed} ` +
`provided=${body.provided})`);
}
return body;
}
async function listFiles(token, limit) {
const out = [];
let page = 1;
while (out.length < limit) {
const body = await call(token, 'files.list',
{ count: 200, page, types: 'all' });
out.push(...(body.files ?? []));
const pages = Number(body.paging?.pages ?? 1);
if (page >= pages) break;
page += 1;
}
return out.slice(0, limit);
}
async function main() {
const token = process.env.SLACK_BOT_TOKEN;
if (!token) {
console.error('set SLACK_BOT_TOKEN (a bot token with files:read is enough)');
process.exitCode = 2;
return;
}
const me = await call(token, 'auth.test');
console.log(`authenticated as ${me.user} in ${me.team}`);
const files = await listFiles(token, 5000);
if (files.length === 0) {
console.log('no files visible to this token');
return;
}
let exposed = 0;
let orphaned = 0;
const ordered = [...files].sort((a, b) =>
Number(b.created ?? 0) - Number(a.created ?? 0));
for (const f of ordered) {
const [state, detail] = verdict(f);
if (state !== 'exposed' && state !== 'exposed-orphan') continue;
exposed += 1;
if (state === 'exposed-orphan') orphaned += 1;
const created = new Date(Number(f.created ?? 0) * 1000).toISOString().slice(0, 10);
console.warn(`${state.padEnd(17)} ${f.id} ${created} ` +
`${humanSize(f.size)} ${(f.name ?? '').slice(0, 48)}`);
console.warn(` ${detail}`);
console.warn(` public link: ${f.permalink_public}`);
console.warn(` repair: files.revokePublicURL?file=${f.id} (needs ` +
'files:write, which this script does not hold)');
}
console.log(`${files.length} file(s), ${exposed} exposed, ${orphaned} ` +
'exposed and unreachable in Slack');
if (exposed) {
console.warn('stop minting public URLs for Block Kit images: host them ' +
'yourself, or reference the uploaded file so channel ' +
'permissions apply.');
}
process.exitCode = exposed ? 1 : 0;
}
// Only run when invoked directly. The test file imports this module, and
// without the guard main() would run there too, fail on the missing token, and
// set a non-zero exit code that fails the whole test file even as every test
// passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The load-bearing test is the third one: a file with is_public set and public_url_shared unset must come back as workspace-visible, never as an exposure. Every screenshot ever posted in a public channel has that shape, and a check that reports them all is a check nobody reads twice.
from slack_public_files_audit import verdict
def test_a_public_url_is_an_exposure():
state, detail = verdict({"public_url_shared": True, "channels": ["C1"]})
assert state == "exposed"
assert "no login" in detail
def test_a_public_url_on_a_file_in_no_channel_is_worse():
state, detail = verdict({"public_url_shared": True,
"channels": [], "groups": [], "ims": []})
assert state == "exposed-orphan"
assert "no channel" in detail
def test_is_public_alone_is_not_an_exposure():
# A screenshot in #general. Slack login still required.
state, detail = verdict({"is_public": True, "channels": ["C1"]})
assert state == "workspace-visible"
assert "Not an exposure" in detail
def test_a_private_file_is_private():
assert verdict({"channels": ["C1"]})[0] == "private"
def test_an_external_file_is_not_judged_by_slack_flags():
state, _ = verdict({"is_external": True, "public_url_shared": True})
assert state == "external"
def test_a_file_with_no_flags_at_all_is_private():
assert verdict({})[0] == "private"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict, humanSize } from './slack-public-files-audit.mjs';
test('a public URL is an exposure', () => {
const [state, detail] = verdict({ public_url_shared: true, channels: ['C1'] });
assert.equal(state, 'exposed');
assert.match(detail, /no login/);
});
test('a public URL on a file in no channel is worse', () => {
const [state, detail] = verdict({
public_url_shared: true, channels: [], groups: [], ims: [],
});
assert.equal(state, 'exposed-orphan');
assert.match(detail, /no channel/);
});
test('is_public alone is not an exposure', () => {
const [state, detail] = verdict({ is_public: true, channels: ['C1'] });
assert.equal(state, 'workspace-visible');
assert.match(detail, /Not an exposure/);
});
test('a private file is private', () => {
assert.equal(verdict({ channels: ['C1'] })[0], 'private');
});
test('an external file is not judged by Slack flags', () => {
assert.equal(verdict({ is_external: true, public_url_shared: true })[0], 'external');
});
test('a file with no flags at all is private', () => {
assert.equal(verdict({})[0], 'private');
});
test('sizes are rendered in the nearest unit', () => {
assert.equal(humanSize(2048), '2KB');
});
FAQ
What is the difference between is_public and public_url_shared?
is_public means the file is shared into a public channel: any member of the workspace can open it, and a Slack login is required. public_url_shared means files.sharedPublicURL was called on it and a permalink_public exists that serves the bytes to anyone on the internet with no account at all. Only the second is a data exposure.
Does the public link expire, or stop working when the message is deleted?
Neither. There is no TTL, and the link is not tied to any message or channel. It survives the file being unshared everywhere, the message being deleted, and the person who created it leaving the company. Only files.revokePublicURL or the file being deleted stops it.
Can I see who has downloaded a public file?
No. There is no access log for permalink_public on any plan. That is why the finding is treated as an exposure rather than as a risk to assess: you cannot establish that nobody used the link, only that the link works.
Why doesn't the script revoke the links itself?
Because it holds a token for your workspace and this section's scripts never write, and because revoking is a judgement call the script cannot make. Some public URLs are load-bearing for a page that embeds them. It prints the exact files.revokePublicURL call per file so you can review the list and run it.
How did this happen if nobody meant to make files public?
Almost always Block Kit. An image_url in a block has to be fetchable without credentials and url_private is not, so files.sharedPublicURL is the first thing that works. Once it is in the upload path it applies to every file the app uploads from then on.
Related field notes
- next_cursor is ignored, so you see one page
- Every failure arrives as HTTP 200
- History clamped to 15 objects per call
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- files.sharedPublicURL method — Slack API
- files.revokePublicURL method — Slack API
- files.list method — Slack API
- files.info method — Slack API
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.