Revoked Tokens by Scope

A medium SQL interview practice problem on DataDriven. Write the query and run it against a real database, with instant feedback.

Domain
SQL
Difficulty
medium
Seniority
L4
Asked in
Southwest Airlines

Problem

Count our revoked API tokens in api_tokens per scope, alphabetically by scope, keeping only those whose expires had not yet passed on December 1, 2025. A token with no expires never lapses, and status capitalization was never cleaned up, so 'REVOKED' and 'revoked' are the same.

Banned tokens, sorted by what they had access to.

Tables

api_tokens

Columns: token_id INTEGER, owner_id INTEGER, scope TEXT, status TEXT, issued TEXT, expires TEXT, last_used TEXT, requests INTEGER

api_tokens: 10 of 200 rows
token_idowner_idscopestatusissuedexpireslast_usedrequests
1043197writerevoked2025-02-022027-02-022026-02-0247
1086294adminexpired2025-03-032027-03-032026-03-0394
1129391read:usersActive2025-04-042027-04-042026-04-04141
1172488write:ordersREVOKED2025-05-052027-05-052026-05-05188
1215585read:analyticsactive2025-06-062027-06-062026-06-06235
1258682full_accessrevoked2025-07-07NULL2026-07-07282
1301779readexpired2025-08-082027-08-082026-08-08329
1344876writeActive2025-09-092027-09-09NULL0
1387973adminREVOKED2025-10-102027-10-102026-01-10423
14301070read:usersactive2025-11-112027-11-112026-02-11470

Expected output

First 5 of 7 rows
scopeblocked_count
admin6
full_access6
read7
read:analytics8
read:users7

Worked solution and explanation

What this really is

This is a liveness check against a cutoff date wearing a security dashboard's badge. Every token's life ends at expires, December 1, 2025 is the cutoff, and the question is which revocations were still guarding something on that day. Anyone can write GROUP BY scope. What separates candidates is remembering that a missing `expires` is a life with no end, and then grouping that alternative so OR does not leak past the status test. Miss the NULL and every permanent ban vanishes; drop the parentheses and active tokens start showing up as revoked.

Build the filter piece by piece

Step 1: Normalize the status

The column holds 'revoked' and 'REVOKED' side by side. LOWER(status) = 'revoked' folds them together; a raw equality quietly undercounts every scope.

Step 2: Keep tokens that had not expired by the cutoff

expires >= '2025-12-01' keeps tokens still alive on the first. A NULL expires makes that comparison unknown, and WHERE discards unknown, so expires IS NULL has to be an explicit alternative.

Step 3: Parenthesize the alternative

AND binds tighter than OR. Written bare, LOWER(status) = 'revoked' AND expires IS NULL OR expires >= ... parses as two separate branches, and the second one admits active and expired tokens of any status.

Unparenthesized
LOWER(status) = 'revoked' AND expires IS NULL OR expires >= '2025-12-01': any token expiring after November slips in, whatever its status.
Grouped
LOWER(status) = 'revoked' AND (expires IS NULL OR expires >= '2025-12-01'): the expiry check is one condition inside the chain, so the status test still applies to every row.

The solution

Revocations still live on the cutoff, per `scope`
SELECT scope, COUNT(*) AS blocked_count
FROM api_tokens
WHERE LOWER(status) = 'revoked'
  AND (expires IS NULL OR expires >= '2025-12-01')
GROUP BY scope
ORDER BY scope

Common follow-up questions