Unique IDs that automatically expire — like UUID but with a TTL
Project description
In plain English
Think about a few things you've used before:
- A password reset link that stops working after 15 minutes
- An invite link that lets exactly 50 people join, then closes
- A one-time download link someone sends you for a file
- An OTP code texted to your phone that only works once, for 5 minutes
All of these are the same idea: something temporary, that should stop working after a time limit or a use limit.
tempid is a small tool developers use to build exactly that, in a few lines of code, without setting up extra infrastructure just for this one feature. If you're not a developer, the rest of this README probably isn't for you — but now you know what the people building your favorite apps reach for when they need a link or code that "expires."
If you are a developer, here's the real documentation.
Table of contents
- Quickstart (5 minutes)
- Learn tempid, one method at a time
- Why tempid instead of JWT or a database table
- How tempid actually works
- Real use cases, with full working code
- Every backend, explained
- Middleware - protect routes automatically
- Async support
- Full API reference
- Security model, explained simply
- Frequently asked questions
- Getting help
Quickstart (5 minutes)
Step 1 — Install
pip install tempid
One dependency (cryptography), which you almost certainly already have.
Step 2 — Set a secret key
export TEMPID_SECRET="$(python -c 'import secrets; print(secrets.token_hex(32))')"
This is the key tempid uses to sign and encrypt every token. Skip this for quick local testing — tempid will warn you and use a fallback key — but never skip it in production, or anyone reading your source code could forge tokens.
Step 3 — Make a token
from tempid import TempID
tid = TempID.new("15m", payload={"user_id": 42})
print(tid)
# TEMP-V2.AIAGU-Q4CGH-GINNF.QE3QB-WPWBI-PGRQ2.HPGZ5-S4JLD-ZTDVD
link = f"https://yourapp.com/reset?token={tid}"
Step 4 — Verify it later
tid = TempID.verify(request.args["token"])
if tid is None:
return "This link is invalid or expired.", 400
user_id = tid.payload["user_id"]
That's a full expiring-token system. Zero infrastructure. If you also want "this can only be used N times," keep reading — it's one extra argument.
Learn tempid, one method at a time
This section walks through every method, one at a time — what you pass in, what you get back, and what it actually prints. No assumptions, no skipped steps. If you're new to Python or new to tempid, start here and run each example yourself.
Run these in a Python shell (
python3) one block at a time, in order — later examples build on tokens created in earlier ones.
TempID.new() — create a token
>>> from tempid import TempID
>>> tid = TempID.new("15m")
>>> tid
TempID('TEMP-V2.AIAGU-Q4CGH-GINNF.HPGZ5-S4JLD-ZTDVD', valid, remaining=14m 59s)
What just happened: TempID.new("15m") created a new token that will stop being valid in 15 minutes. The string "15m" means 15 minutes. You can also use "30s" (seconds), "2h" (hours), or "7d" (days).
tid is now an object — not just a string. Printing it (as Python does automatically when you type tid in the shell) shows you its current status and time remaining, which is handy for debugging.
print(tid) — get the actual token string
>>> print(tid)
TEMP-V2.AIAGU-Q4CGH-GINNF.HPGZ5-S4JLD-ZTDVD
What just happened: This is the actual string you'd put in a URL or send in an email. It's what str(tid) returns, and it's what print() shows by default for any tempid token. Save this — you'll need it for the next example.
tid.valid() — check if it's still good
>>> tid.valid()
True
What just happened: Returns True because we created this token seconds ago with a 15-minute expiry. If you waited 16 minutes and called this again, it would return False.
>>> short_lived = TempID.new("1s")
>>> import time
>>> time.sleep(2)
>>> short_lived.valid()
False
What just happened: We made a token that expires in 1 second, waited 2 seconds, then checked. It correctly returned False.
tid.expired() — the opposite of valid()
>>> tid.expired()
False
>>> short_lived.expired()
True
What just happened: expired() always returns the opposite of valid(). It exists purely so your code can read naturally — if tid.expired(): ... is clearer than if not tid.valid(): ... in some contexts. Use whichever reads better in your code.
tid.remaining() — human-readable time left
>>> tid.remaining()
'14m 52s'
What just happened: Returns a short string showing time left, automatically choosing the right format:
>>> TempID.new("45s").remaining()
'45s'
>>> TempID.new("90m").remaining()
'1h 30m'
>>> TempID.new("3d").remaining()
'3d 0h'
>>> short_lived.remaining()
'expired'
What just happened: Seconds-only for under a minute, "Xm Ys" for under an hour, "Xh Ym" for under a day, "Xd Yh" beyond that. Once a token has expired, this always returns the literal string 'expired'.
TempID.verify() — the safe way to check a token someone sends you
>>> token_string = "TEMP-V2.AIAGU-Q4CGH-GINNF.HPGZ5-S4JLD-ZTDVD"
>>> result = TempID.verify(token_string)
>>> result
TempID('TEMP-V2.AIAGU-Q4CGH-GINNF.HPGZ5-S4JLD-ZTDVD', valid, remaining=14m 31s)
What just happened: verify() takes a raw string (exactly what a user would paste into a URL) and gives you back a usable TempID object — if it's genuine and not expired. This is what you call when a request comes in from outside your code.
>>> TempID.verify("this-is-not-a-real-token")
>>> print(TempID.verify("this-is-not-a-real-token"))
None
What just happened: Garbage input returns None — it never raises an error. This is the whole point of verify(): you can always safely write if TempID.verify(token): without wrapping it in a try/except.
>>> tampered = token_string[:-1] + "X" # change the very last character
>>> print(TempID.verify(tampered))
None
What just happened: Even changing a single character breaks the signature, so verify() rejects it — again, returning None instead of raising.
TempID.from_string() — the strict version of verify()
>>> tid2 = TempID.from_string(token_string)
>>> tid2
TempID('TEMP-V2.AIAGU-Q4CGH-GINNF.HPGZ5-S4JLD-ZTDVD', valid, remaining=14m 10s)
What just happened: Same idea as verify(), but for a valid token it behaves the same. The difference shows up with invalid input:
>>> TempID.from_string("garbage-token")
Traceback (most recent call last):
...
tempid.exceptions.TempIDFormatError: Invalid V2 token format: 'garbage-token'
What just happened: Unlike verify(), from_string() raises an exception instead of quietly returning None. Use from_string() only if you specifically want to catch and handle different failure reasons (TempIDFormatError vs TempIDTamperedError) separately. For everyday use, prefer verify().
payload={...} — attaching your own data to a token
>>> tid3 = TempID.new("10m", payload={"user_id": 42, "email": "demo@example.com"})
>>> tid3.payload
{'user_id': 42, 'email': 'demo@example.com'}
What just happened: Anything JSON-serializable (numbers, strings, lists, nested dicts) can be attached as payload. It's compressed and encrypted into the token itself — there's no separate database lookup needed to get this data back.
>>> str(tid3)
'TEMP-V2.AIAGU-...much-longer-string-here...VYR5U'
>>> recovered = TempID.verify(str(tid3))
>>> recovered.payload
{'user_id': 42, 'email': 'demo@example.com'}
What just happened: We turned the token into a string (as if sending it in a URL), then verified it back — and the payload came through intact. This proves the payload travels inside the token string itself.
>>> print(TempID.new("10m").payload)
None
What just happened: If you don't pass payload, tid.payload is simply None. There's no payload section in the token string at all in that case (the token is shorter).
max_uses=... and tid.use() — limiting how many times a token works
This one needs a backend configured first, because something has to remember "how many times has this been used" between separate calls to your app.
>>> from tempid import configure
>>> from tempid.backends import MemoryBackend
>>> configure(store=MemoryBackend())
What just happened: MemoryBackend() is the simplest possible backend — it just remembers use-counts in a Python dictionary while your program is running. Perfect for trying this out; not suitable for production (more on that further down).
>>> limited = TempID.new("1h", max_uses=2)
>>> limited.use()
True
>>> limited.use()
True
>>> limited.use()
False
What just happened: We created a token allowed to be used 2 times. The first two calls to .use() returned True (allowed). The third call returned False — the limit was reached, and tempid blocked it.
>>> limited.uses_info()
{'total': 2, 'used': 2, 'left': 0}
What just happened: uses_info() gives you a snapshot dictionary — handy for showing a user "you've used this 2 of 2 times" without writing that logic yourself.
>>> unlimited = TempID.new("1h") # no max_uses passed
>>> unlimited.use()
True
>>> unlimited.use()
True
>>> unlimited.uses_info()
{'total': None, 'used': None, 'left': None}
What just happened: Without max_uses, .use() always returns True instantly — it doesn't even check the backend, because there's no limit to check against.
check_uses=True — checking the limit without consuming a use
>>> still_has_uses = TempID.verify(str(limited), check_uses=True)
>>> print(still_has_uses)
None
What just happened: Remember, limited already hit its max_uses=2 cap above. Passing check_uses=True to verify() makes it also check the backend's use-count — and since the limit was already reached, it returns None, just like an expired or tampered token would. Importantly, this check does not itself consume a use — it's read-only, so you can call it as many times as you like (e.g. to show "0 uses left" on a page) without affecting the actual count.
tid.on_expire() — running code automatically when a token expires
>>> reminder = TempID.new("1s")
>>> reminder.on_expire(lambda: print("This token just expired!"))
TempID('...', valid, remaining=1s)
>>> import time; time.sleep(2)
>>> reminder.valid()
This token just expired!
False
What just happened: on_expire() registers a function to run the first time tempid notices the token has expired — which happens the next time you call .valid() (or .expired()) after the expiry time has passed. Notice the callback printed its message during the .valid() call, right before it returned False. This is useful for cleanup — e.g. deleting a temporary session record the moment it's no longer needed.
That's the entire surface area of tempid. Every method you'll ever call is one of the ones above. The rest of this README covers why you'd reach for each one, and how to wire max_uses into a real production backend like Redis instead of MemoryBackend.
Why tempid instead of JWT or a database table
| Raw JWT | DIY database table | tempid | |
|---|---|---|---|
| Verifies without touching a DB | Yes | No | Yes |
| Built-in expiry | Yes (you wire it up) | Manual | Yes (automatic) |
| Payload is actually encrypted | No — just base64 | Depends on your schema | Yes — AES-GCM |
| "Use this exactly N times" limit | No | Manual, race-prone | Yes — built-in, atomic |
| Tamper detection | Yes | N/A | Yes — HMAC-SHA256 |
| Needs a migration / new table | No | Yes | No (unless you want use-limits) |
| Needs a cleanup cron job | No | Yes | No |
Honestly: JWT is built to be a portable identity assertion that multiple independent services can verify on their own — that's a real, useful problem, and JWT is the right tool for it.
A password reset link isn't that. It's a short-lived action token one part of your app issues and another part checks five minutes later. JWT works for that, but you end up hand-rolling claims, remembering payloads are only base64 (readable, not encrypted), and building your own use-count tracking — because JWT doesn't have one.
tempid is built to do that one job well.
How tempid actually works
A token is one string you can put straight into a URL:
TEMP-V2.AIAGU-Q4CGH-GINNF.QE3QB-WPWBI-PGRQ2-7DX2U.HPGZ5-S4JLD-ZTDVD
^ ^ header ^ encrypted payload ^ signature
version
- Header — when this token expires, a random nonce so two tokens are never identical, and the use-limit.
- Payload (optional) — your data (e.g.
{"user_id": 42}), JSON-encoded, compressed, then encrypted with AES-GCM. Nobody who intercepts this token can read it. - Signature — HMAC-SHA256 over the header and payload. Change one character, verification fails.
verify() checks the signature first, before decoding anything else. A garbage token gets rejected in microseconds, zero database calls made — so spamming your endpoint with fake tokens can't be used to hammer your database.
Real use cases, with full working code
These are complete, copy-pasteable examples — not fragments.
1. Password reset email
from tempid import TempID
# When the user clicks "Forgot password"
tid = TempID.new("15m", payload={"user_id": user.id})
send_email(user.email, link=f"https://app.com/reset?token={tid}")
# When they click the link in their email
tid = TempID.verify(request.args["token"])
if tid is None:
return "This link has expired. Please request a new one.", 400
show_reset_password_form(user_id=tid.payload["user_id"])
2. Magic login link (no password typing)
tid = TempID.new("10m", payload={"email": user.email}, max_uses=1)
send_email(user.email, link=f"https://app.com/login?token={tid}")
# When clicked:
tid = TempID.verify(token, check_uses=True)
if tid and tid.use():
log_in_user(tid.payload["email"])
else:
return "This link has expired or was already used.", 400
3. Invite link, usable by up to 50 people, valid for a week
tid = TempID.new("7d", payload={"team_id": 7}, max_uses=50)
invite_link = f"https://app.com/join?token={tid}"
# Post this one link in Slack, an email, a poster — it tracks its own usage.
4. One-time file download link
tid = TempID.new("1h", payload={"file_id": "abc123"}, max_uses=3)
download_link = f"https://app.com/download?token={tid}"
5. OTP / 2FA code with attempt limiting
otp_token = TempID.new("5m", payload={"user_id": 42}, max_uses=1)
# Send this token string directly via SMS.
# It expires in 5 minutes OR after exactly 1 successful login.
Using a Database for Strict Limits (max_uses)
If you only need tokens to expire after a certain time, you do not need a database.
However, if you want a token to expire after exactly 1 or 5 uses (using max_uses=...), tempid needs to connect to your database to safely count how many times it was used.
Here is how to configure each type of database. Important: Always call teardown() when your app shuts down to safely close the database connections!
| Backend | Best for | Survives restart? | Multi-server safe? |
|---|---|---|---|
MemoryBackend |
Local dev, scripts, tests | No | No |
SQLiteBackend |
Single-machine apps | Yes | No |
RedisBackend |
Production (recommended) | Yes | Yes |
PostgreSQLBackend |
Teams already on Postgres | Yes | Yes |
MySQLBackend |
Teams already on MySQL | Yes | Yes |
MongoBackend |
Teams already on MongoDB | Yes | Yes |
MemoryBackend (the default — no setup needed)
from tempid import TempID, configure
from tempid.backends import MemoryBackend
configure(store=MemoryBackend())
tid = TempID.new("1h", max_uses=1)
print(tid.use()) # True — first use, allowed
print(tid.use()) # False — already used, blocked
Warning: don't use this with more than one worker process (Gunicorn, Lambda, anything horizontally scaled) — each process has separate memory, so a max_uses=1 token could be used once per worker.
SQLiteBackend
from tempid.backends import SQLiteBackend
configure(store=SQLiteBackend("tempid_uses.db"))
Use-counts survive a restart, but only works on a single machine.
RedisBackend (recommended for production)
pip install "tempid[redis]"
from tempid.backends import RedisBackend
configure(store=RedisBackend("redis://localhost:6379"))
tid = TempID.new("15m", max_uses=1)
print(tid.use()) # True
print(tid.use()) # False — atomic, even if two requests race at the exact same moment
The check-and-increment happens as one atomic Redis Lua script — no window for a double-click on an email link to slip through.
PostgreSQLBackend
pip install "tempid[postgres]"
from tempid.backends import PostgreSQLBackend
configure(store=PostgreSQLBackend(dsn="postgresql://user:pass@localhost/myapp"))
Pool sizing note: if you expect 100 concurrent requests, set max_conn to at least that, or you'll see PoolError from requests that couldn't get a connection.
MySQLBackend
pip install "tempid[mysql]"
from tempid.backends import MySQLBackend
configure(store=MySQLBackend(host="localhost", user="root", password="...", db="myapp"))
MongoBackend
pip install "tempid[mongo]"
from tempid.backends import MongoBackend
configure(store=MongoBackend("mongodb://localhost:27017", db="myapp"))
Middleware — protect routes automatically
Flask
from functools import wraps
from flask import Flask, request, jsonify
from tempid import TempID, configure
from tempid.backends import RedisBackend
app = Flask(__name__)
configure(store=RedisBackend("redis://localhost:6379"))
def limited_use_token(check_uses=True):
def decorator(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
token = request.args.get("token") or request.headers.get("X-Token")
if not token:
return jsonify({"error": "Missing token"}), 401
tid = TempID.verify(token, check_uses=check_uses)
if tid is None:
return jsonify({"error": "Token is invalid, expired, or already used"}), 401
if tid.max_uses > 0 and not tid.use():
return jsonify({"error": "Token has reached its use limit"}), 401
request.tempid = tid
return view_func(*args, **kwargs)
return wrapped
return decorator
@app.route("/reset-password", methods=["POST"])
@limited_use_token()
def reset_password():
user_id = request.tempid.payload["user_id"]
return jsonify({"status": "password reset successful"})
FastAPI
from fastapi import FastAPI, Depends, HTTPException, Query
from tempid import TempID, configure
from tempid.async_backends import AsyncRedisBackend
app = FastAPI()
configure(store=AsyncRedisBackend("redis://localhost:6379"))
async def verified_token(token: str = Query(...)) -> TempID:
tid = await TempID.verify_async(token, check_uses=True)
if tid is None:
raise HTTPException(status_code=401, detail="Token is invalid, expired, or already used")
if tid.max_uses > 0 and not await tid.use_async():
raise HTTPException(status_code=401, detail="Token has reached its use limit")
return tid
@app.post("/reset-password")
async def reset_password(tid: TempID = Depends(verified_token)):
user_id = tid.payload["user_id"]
return {"status": "password reset successful"}
Django
from functools import wraps
from django.http import JsonResponse
from tempid import TempID, configure
from tempid.backends import PostgreSQLBackend
configure(store=PostgreSQLBackend(dsn="postgresql://user:pass@localhost/myapp"))
def limited_use_token(view_func):
@wraps(view_func)
def wrapped(request, *args, **kwargs):
token = request.GET.get("token") or request.headers.get("X-Token")
if not token:
return JsonResponse({"error": "Missing token"}, status=401)
tid = TempID.verify(token, check_uses=True)
if tid is None:
return JsonResponse({"error": "Token is invalid, expired, or already used"}, status=401)
if tid.max_uses > 0 and not tid.use():
return JsonResponse({"error": "Token has reached its use limit"}, status=401)
request.tempid = tid
return view_func(request, *args, **kwargs)
return wrapped
@limited_use_token
def reset_password(request):
user_id = request.tempid.payload["user_id"]
return JsonResponse({"status": "password reset successful"})
Async support, end to end
from tempid import TempID, configure
from tempid.async_backends import AsyncRedisBackend
configure(store=AsyncRedisBackend("redis://localhost:6379"))
async def handle_reset(token: str):
tid = await TempID.verify_async(token, check_uses=True)
if tid is None:
return error_response()
if not await tid.use_async():
return error_response("Already used")
return success_response(tid.payload)
pip install "tempid[async-redis]" # AsyncRedisBackend
pip install "tempid[async-sqlite]" # AsyncSQLiteBackend
pip install "tempid[async-mongo]" # AsyncMongoBackend
pip install "tempid[async-mysql]" # AsyncMySQLBackend
pip install "tempid[async-postgres]" # AsyncPostgreSQLBackend
Mixing sync and async by accident is caught for you:
tid.use()
# RuntimeError: Configured backend is async. Use 'await tid.use_async()' instead.
Full API reference
Creating tokens
TempID.new(
expires_in: str = "10m", # "30s", "10m", "2h", "7d"
payload: dict | None = None, # JSON-serializable dict, up to 512 bytes once serialized
max_uses: int = 0, # 0 = unlimited, 1-255 = limited
) -> TempID
Reading tokens back
TempID.from_string(value: str) -> TempID
Raises TempIDFormatError or TempIDTamperedError if invalid — use this if you want to handle errors yourself.
TempID.verify(value: str, check_uses: bool = False) -> TempID | None
Never raises — returns None for any failure. check_uses=True confirms the limit hasn't been hit (read-only, doesn't consume a use).
TempID.verify_async(value: str, check_uses: bool = False) -> TempID | None
Async version, requires an async backend configured.
Checking state
tid.valid() -> bool
tid.expired() -> bool
tid.remaining() -> str # "14m 59s", "2h 3m", "6d 23h", or "expired"
Consuming uses
tid.use() -> bool # True if allowed, False if limit reached
tid.use_async() -> bool # async version
tid.uses_info() -> dict # {"total": 3, "used": 1, "left": 2}
tid.uses_info_async() -> dict # async version
Expiry callbacks
tid.on_expire(callback: Callable[[], None]) -> TempID
tid.on_expire(cleanup_session).on_expire(log_expiry) # chainable
Configuring a backend
from tempid import configure
configure(store=SomeBackend(...)) # call once, at app startup
Security model, explained simply
- Every token is signed with HMAC-SHA256 — change one character, verification fails.
- Every payload is encrypted with AES-GCM, the same authenticated encryption family used in TLS. Not just encoded — genuinely unreadable without the secret.
- Signature is checked before anything else is decoded — forged tokens are rejected instantly, with zero database calls.
- Your secret lives in one environment variable:
export TEMPID_SECRET="$(python -c 'import secrets; print(secrets.token_hex(32))')"
If unset, tempid warns once and falls back to an insecure default — fine for local dev, never for production.
Frequently asked questions
Is this a replacement for JWT? For short-lived, single-purpose action tokens — yes, and simpler. For identity assertions verified independently across multiple services, JWT is still the right call.
Do I need a database at all?
Not for expiry, signing, or encrypted payloads — those live entirely inside the token string. Only max_uses needs a backend.
What if I forget to set TEMPID_SECRET?
One UserWarning, then an insecure fallback. Works for local testing — never deploy to production without setting it.
Can this run across multiple servers?
Always, for expiry-only tokens. For max_uses tokens, use anything except MemoryBackend.
How big can the payload be?
Up to 512 bytes after JSON serialization — enough for IDs or emails, not for storing actual data. Treat it as a pointer ({"user_id": 42}), not a database.
Is this production-ready?
Yes — every use-count backend has been stress-tested at 1,000 concurrent requests against a strict max_uses limit, with the limit enforced exactly every time, no double-spending, on every backend.
Getting help
- Found a bug? Open an issue with a small reproducible snippet if you can.
- Have an idea or a question? Issues are fine for those too.
- Something here confusing? That's on the docs, not on you — tell us and we'll fix it.
Contributing
- Open an issue first for anything beyond a small fix.
- Run
pytestbefore submitting. - New backends or features should come with a matching README section.
License
MIT — see LICENSE.
If tempid saved you from writing yet another tokens table and a cleanup cron job, a star on the repo helps other developers find it.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tempid-2.0.1.tar.gz.
File metadata
- Download URL: tempid-2.0.1.tar.gz
- Upload date:
- Size: 40.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f20f8f5e663e46c30d284e1547174a38ae4a24c96f4dffbfe857fecf10b7a767
|
|
| MD5 |
b72935de467f55ab8c26d142571e0840
|
|
| BLAKE2b-256 |
de90b1360c65edfcdfb4b8094266bd4a98c06ac2ccd0d37545098a49bf3ab709
|
File details
Details for the file tempid-2.0.1-py3-none-any.whl.
File metadata
- Download URL: tempid-2.0.1-py3-none-any.whl
- Upload date:
- Size: 27.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4f2ffb4ec9a9583d73828e24871c8847ac46933f806b60d55f8beeebc5071dd0
|
|
| MD5 |
1c920cd568c9a36c369130479648101c
|
|
| BLAKE2b-256 |
358b9e08eea6c582e87fb50a44b1dc11725f360532d0d7a0fff949b11b84fe20
|