Unix Epoch Time Explained: Timestamps in Security
Unix epoch time is the number of seconds that have passed since 1970-01-01T00:00:00Z (midnight UTC on 1 January 1970), not counting leap seconds. A value like 1700000000 is a moment in time. It always refers to UTC, and it has no time zone attached. Read in seconds, that number is 2023-11-14T22:13:20Z. Read as milliseconds, it points to a date in January 1970. Getting that difference wrong is behind many token, logging and forensic bugs.
Just need to convert a value? Paste it into the free Epoch & Unix Timestamp Converter. It works out whether the value is in seconds or milliseconds and shows ISO 8601, UTC and relative time. Everything runs in your browser. The rest of this article explains how to read the number and why it matters in security work.
What "epoch time" actually means
The formal definition comes from POSIX. IEEE Std 1003.1 §4.19, "Seconds Since the Epoch" defines it as a value that approximates the seconds elapsed since the Epoch, and it adds a key rule: "each and every day shall be accounted for by exactly 86400 seconds."
That rule has three practical consequences:
- It is always UTC. The number itself has no time zone.
1700000000names the same instant in Colombo, London and New York. Only how you display it changes. - Leap seconds are ignored. Since every day is exactly 86,400 seconds, a UTC leap second has no number of its own. That keeps arithmetic simple:
1700000000 / 86400is 19,675 whole days plus 80,000 seconds. Epoch time is not a precise count of physical SI seconds, though. - Zero is the epoch.
0is1970-01-01T00:00:00Z. Negative values are before 1970. POSIX leaves negative values undefined, but most modern systems handle them.
You can check this from any Linux shell with GNU date. Per the date(1) man page, -d @N reads a raw epoch, -u prints UTC, and %s prints seconds since the epoch:
# Epoch -> human (always add -u, or you get your machine's local time)
date -u -d @1700000000
# Tue Nov 14 22:13:20 UTC 2023
date -u -d @1700000000 +%Y-%m-%dT%H:%M:%SZ
# 2023-11-14T22:13:20Z
# Human -> epoch (give an explicit zone)
date -u -d '2023-11-14T22:13:20Z' +%s
# 1700000000
# "Now" as epoch seconds
date +%s
# 1790596894 (the moment this was written: 2026-09-28T12:01:34Z)
The same instant printed in two local zones shows why UTC matters:
TZ=Asia/Colombo date -d @1700000000 '+%F %T %z' # 2023-11-15 03:43:20 +0530
TZ=America/New_York date -d @1700000000 '+%F %T %Z' # 2023-11-14 17:13:20 EST
It is one timestamp, yet the local dates fall on different calendar days. Keep that in mind when you get to the timeline section.
Seconds, milliseconds, microseconds, nanoseconds
Epoch time is not always stored in seconds. Different systems count different units from the same 1970 starting point:
| Unit | Digits for current dates | Example (same instant) | Where you'll see it |
|---|---|---|---|
| seconds | 10 | 1700000000 | JWT claims, date +%s, many APIs and databases |
| milliseconds | 13 | 1700000000123 | JavaScript Date.now(), Java, many JSON APIs |
| microseconds | 16 | 1700000000123456 | some databases and tracing systems |
| nanoseconds | 19 | 1700000000123456789 | high-resolution logs and telemetry |
The digit-count rule of thumb comes from real boundaries. Every date from 2001-09-09T01:46:40Z (1000000000) to 2286-11-20T17:46:39Z (9999999999) is exactly 10 digits in seconds. So for anything recent, 10 digits means seconds and 13 means milliseconds.
For code, a magnitude check is more robust than counting characters. The cut-off 10^11 works well: as seconds it is the year 5138, and as milliseconds it is 1973-03-03. Below it, assume seconds. Above it, assume milliseconds or finer. The same logic scales up by 1,000 for microseconds and nanoseconds.
The classic unit bugs
- Milliseconds read as seconds.
1700000000000read as seconds is+55840-11-08T22:13:20Z, roughly 53,800 years in the future. A 13-digitexpin a token that a validator treats as seconds makes the token effectively permanent. - Seconds read as milliseconds. In JavaScript,
new Date(1700000000)gives1970-01-20T16:13:20.000Z. Dates in January 1970 on a dashboard almost always come from this bug. - Float rounding at nanosecond precision. A 19-digit value is bigger than a 64-bit float can store exactly. In Python,
1700000000123456789 / 1e9evaluates to1700000000.1234567, and the last digits are lost. Use integer division when precision matters. - Truncation vs. rounding. When converting ms to s, floor the value (
Math.floor,//). Don't round. Rounding can move a timestamp into the next second, which matters forexpand for ordering events.
Python: convert and detect the unit
Python's datetime.fromtimestamp() returns local, naive time unless you pass a tz. Always pass timezone.utc:
from datetime import datetime, timedelta, timezone
UNITS = [(10**17, "ns", 10**9), (10**14, "us", 10**6), (10**11, "ms", 10**3), (0, "s", 1)]
def epoch_to_utc(value: int):
"""Guess the unit from magnitude and return (unit, aware UTC datetime)."""
for threshold, unit, per_second in UNITS:
if abs(value) >= threshold:
secs, frac = divmod(value, per_second) # integer math: no float rounding
dt = datetime.fromtimestamp(secs, tz=timezone.utc)
return unit, dt + timedelta(microseconds=frac * 10**6 // per_second)
for v in (1700000000, 1700000000123, 1700000000123456, 1700000000123456789):
unit, dt = epoch_to_utc(v)
print(f"{v:>20} {len(str(v)):>2} digits {unit:<2} {dt.isoformat()}")
# Back to epoch seconds from an explicit-UTC ISO string (a trailing "Z" needs Python 3.11+)
print(int(datetime.fromisoformat("2023-11-14T22:13:20Z").timestamp()))
Output:
1700000000 10 digits s 2023-11-14T22:13:20+00:00
1700000000123 13 digits ms 2023-11-14T22:13:20.123000+00:00
1700000000123456 16 digits us 2023-11-14T22:13:20.123456+00:00
1700000000123456789 19 digits ns 2023-11-14T22:13:20.123456+00:00
1700000000
Python's datetime stores microseconds, so the last three digits of the nanosecond value (789) are dropped. Keep the raw integer if you need them. Also avoid datetime.utcfromtimestamp(), which is deprecated since Python 3.12 and returns a naive object. The docs recommend datetime.fromtimestamp(ts, tz=timezone.utc).
JavaScript: Date.now() is milliseconds
Date.now() returns milliseconds since the epoch. JWTs and most back-end APIs expect seconds:
const ms = Date.now(); // milliseconds since the epoch (13 digits today)
const s = Math.floor(Date.now() / 1000); // seconds since the epoch (10 digits today), use for JWT exp/iat
new Date(1700000000 * 1000).toISOString(); // "2023-11-14T22:13:20.000Z" (correct: seconds -> ms)
new Date(1700000000).toISOString(); // "1970-01-20T16:13:20.000Z" (bug: seconds passed as ms)
Epoch time in JWTs: exp, iat and nbf
JSON Web Tokens use epoch seconds for their time claims. RFC 7519 §2 defines NumericDate as "the number of seconds from 1970-01-01T00:00:00Z UTC until the specified UTC date/time, ignoring leap seconds". That is the POSIX definition again, except that non-integer values are allowed. Seconds, not milliseconds.
The three registered time claims:
exp(Expiration Time), §4.1.4: the token "MUST NOT be accepted for processing" on or after this time. The current time must be beforeexp.nbf(Not Before), §4.1.5: the token must not be accepted before this time.iat(Issued At), §4.1.6: when the token was issued, which tells you its age.
All three are optional in the RFC. Whether a token must carry exp is up to your application. For access tokens, the answer should almost always be yes.
Reading exp from a real token
A JWT is three Base64URL segments joined by dots. The middle segment is the claims. Here is a demo token (HS256, signed with a throwaway secret, not a real credential):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vLXVzZXIiLCJpYXQiOjE3MDAwMDAwMDAsIm5iZiI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDAzNjAwfQ.hhplIyBQbM1T-RfuZK4o-bVpnkAmhfVfkNzmP64XqBs
Decode the payload in bash. Base64URL uses - and _ and drops the = padding, so translate those characters and add the padding back before calling base64 -d:
payload=$(printf '%s' "$TOKEN" | cut -d. -f2 | tr '_-' '/+')
while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="${payload}="; done
printf '%s' "$payload" | base64 -d; echo
# {"sub":"demo-user","iat":1700000000,"nbf":1700000000,"exp":1700003600}
date -u -d @1700003600 +%FT%TZ
# 2023-11-14T23:13:20Z
So this token was issued at 2023-11-14T22:13:20Z and expired exactly one hour later (exp - iat = 3600). If you'd rather not use the shell, paste the token into the JWT Debugger, decode just the middle segment with the Base64 decoder, or build a From Base64URL → JSON Beautify recipe in the Cyber Swiss Army Knife. Then drop the exp value into the timestamp converter.
Decoding is not verifying. Anyone can read and edit the claims. RFC 7519 §11.1 is clear that contents can't be trusted unless they are cryptographically secured. Check the signature with a proper library before you act on exp or any other claim.
Clock skew and leeway
Issuer and verifier clocks never agree exactly. For exp and nbf, RFC 7519 lets implementers "provide for some small leeway, usually no more than a few minutes, to account for clock skew." Keep it small. Leeway also extends the useful life of a stolen token.
Common expiry validation mistakes
- No
expat all. A token with no expiry stays valid until the signing key is rotated. - Millisecond
exp. An issuer writesDate.now() + 3600000(a 13-digit value), and a seconds-based verifier reads it as a date tens of thousands of years away. This is bug #1 from the unit section, now in your auth layer. - Off-by-one comparisons. The RFC says reject on or after
exp. That meansnow >= expfails, notnow > exp. - Ignoring
nbf, or aniatin the future. Aniatwell ahead of the verifier's clock points to skew or a token minted with a manipulated clock. Either way, log it. - Comparing formatted strings. Compare integers in UTC. Don't compare local date strings.
- Trusting decoded claims before signature verification (see above).
A minimal time-claim check, after the signature has been verified:
import time
def check_time_claims(claims: dict, now: int | None = None, leeway: int = 60) -> str:
now = int(time.time()) if now is None else now
exp, nbf, iat = claims.get("exp"), claims.get("nbf"), claims.get("iat")
if exp is None:
return "reject: no exp"
if exp > 10**11: # 13+ digits: almost certainly milliseconds
return "reject: exp looks like milliseconds"
if now >= exp + leeway:
return "reject: expired"
if nbf is not None and now + leeway < nbf:
return "reject: not yet valid"
if iat is not None and iat > now + leeway:
return "reject: issued in the future"
return "ok"
claims = {"iat": 1700000000, "nbf": 1700000000, "exp": 1700003600}
check_time_claims(claims, now=1700003599) # 'ok'
check_time_claims(claims, now=1700003660) # 'reject: expired' (exp + 60 s leeway)
In production, use your JWT library's built-in exp/nbf/leeway options rather than writing your own. This sketch just shows what those options should be doing.
Building log and forensic timelines
In incident response, the timeline is the case. Most timeline mistakes come from mixing time formats and time zones, not from missing data.
Normalize everything to UTC and keep the raw value
A workable rule for every artifact you ingest:
- Store the original raw value (epoch integer, FILETIME, or log string) exactly as found.
- Record its unit and source clock (seconds or ms, which host, NTP-synced or not).
- Add a normalized UTC ISO 8601 column (
2023-11-14T22:13:20Z) and sort on that. - Convert to local time only for display, and label it.
Your normalized column only helps if it is right. Python's docs warn that naive datetime objects are "treated by many datetime methods as local times". Run the same datetime.fromtimestamp(1700000000) on a laptop set to Asia/Colombo and it prints 2023-11-15 03:43:20, a different calendar day from the UTC value. A reviewer on another machine gets another answer. Aware UTC datetimes avoid this.
Windows FILETIME vs Unix time
Windows keeps time differently. A FILETIME is "a 64-bit value representing the number of 100-nanosecond intervals since January 1, 1601 (UTC)." The epoch is different and so is the unit.
The gap between 1601-01-01 and 1970-01-01 is 134,774 days, or 11,644,473,600 seconds. In 100 ns ticks that is 116444736000000000, the same constant Microsoft uses in its time_t-to-FILETIME example:
EPOCH_DIFF_100NS = 116444736000000000 # 1601-01-01 -> 1970-01-01 in 100 ns ticks
def filetime_to_unix(ft: int) -> float:
return (ft - EPOCH_DIFF_100NS) / 10**7
def unix_to_filetime(t: int) -> int:
return t * 10**7 + EPOCH_DIFF_100NS
unix_to_filetime(1700000000) # 133444736000000000
filetime_to_unix(133444736000000000) # 1700000000.0
A FILETIME is 18 digits for current dates, which is easy to confuse with a nanosecond epoch (19 digits). If a "timestamp" won't convert or lands in the wrong century, check whether it is FILETIME. Microsoft's FILETIME documentation notes that NTFS records times in UTC, but FAT records local time, and that FAT's write time has 2-second resolution. A FAT-formatted USB stick in your evidence carries local time, so find out which zone the host was set to.
Time zone pitfalls that break correlation
- Local-time logs without an offset. A line like
2023-11-15 03:43:20could be almost any UTC instant. Get the host's zone and DST rules for that date before you normalize. - DST fold. When clocks go back, one local hour happens twice. The Python docs describe how
foldtells the two apart. Epoch values never have this problem. - Clock skew between hosts. A host that is two minutes fast can make an effect appear before its cause. Record NTP status. Where possible, anchor each host's offset to an event seen by two systems, such as an authentication recorded by both the client and the server.
- Recorded order is not causal order. Logs record when an event was written, and buffering, batching and ingestion delay all shift that. Treat second-level ordering across hosts as a hypothesis.
Timestamp manipulation (timestomping): the defender's view
Attackers know investigators rely on timestamps. MITRE ATT&CK tracks this as T1070.006 Timestomp: changing a file's modify, access, create or change times so malicious files blend in with legitimate ones.
What defenders can use, conceptually:
- Timestamps that disagree with each other. On NTFS, MITRE notes that
$STANDARD_INFORMATION($SI) is easy to change through user-level APIs, while$FILE_NAME($FN) usually requires kernel interaction or a move or rename. A$SItime earlier than the matching$FNtime is a classic red flag. MITRE also documents "double timestomping", where both are changed, so don't rely on this check alone. - Change time on Unix-like systems. POSIX
futimens/utimensatset access and modification times, but a successful call also "mark[s] the last file status change timestamp for update." A backdated mtime next to a recent ctime is worth a closer look. - Corroborate against independent clocks. File metadata is one source. Also check event logs, EDR telemetry, proxy logs and memory artifacts, which record time independently. Our memory forensics & IR pipeline guide covers pulling process data from RAM that doesn't depend on disk timestamps.
- Flag impossible values. Look for future dates, pre-install dates, or many files sharing an identical timestamp in a directory where that shouldn't happen.
The goal is detection and reliable timelines. When timestamps disagree, trust the corroborated sources over the easily edited ones.
The Year 2038 problem
Many older systems store epoch seconds in a signed 32-bit integer. The largest value it can hold is 2^31 - 1 = 2147483647:
date -u -d @2147483647 +%FT%TZ # 2038-01-19T03:14:07Z (last representable second)
date -u -d @-2147483648 +%FT%TZ # 1901-12-13T20:45:52Z (where a wrapped counter lands)
One second later, a signed 32-bit counter overflows to -2147483648, which reads as December 1901. The man page for date even uses @2147483647 as its example. An unsigned 32-bit field holds on until 4294967295 (2106-02-07T06:28:15Z), but then can't store dates before 1970.
Why this is a security problem and not just a calendar one:
- Long-lived expiries. Certificates, licences, token
expvalues and "valid until" fields set 15+ years ahead already cross 2038. A 32-bit parser can wrap them into the past (instantly expired, denial of service) or mishandle the comparison in ways that fail open. - Embedded and legacy systems. Firmware, industrial controllers, old file formats and database columns typed as 32-bit integers stay in service for decades.
- Platform limits. Python's docs warn that
fromtimestamp()is commonly "restricted to years in 1970 through 2038" on some platforms, depending on the C library.
The fix is 64-bit time everywhere a timestamp is stored, parsed or sent. Test with dates past 2038 now, including exp values in your auth test suite.
A closing note on time-bound credentials and AI agents
Every idea in this article (short exp, strict unit handling, UTC everywhere, small leeway) matters more when the thing holding the token isn't a person. AI agents and MCP servers increasingly carry API keys, OAuth tokens and session credentials to real systems. A long-lived or mis-validated token in an agent's toolchain is a standing invitation. Keep those credentials short-lived and check expiry on every call. If you run MCP servers, consider a runtime layer in front of them. SentinelAgent Guard is an MCP security firewall that provides runtime protection for MCP servers.
FAQ
Is a Unix timestamp in UTC or local time?
UTC, always. Epoch time counts seconds since 1970-01-01T00:00:00Z and carries no zone. Localize only when you display it, and label the zone you used.
How do I tell if a timestamp is in seconds or milliseconds?
For current dates, 10 digits means seconds and 13 means milliseconds (16 is microseconds, 19 is nanoseconds). In code, treat values of 10^11 or more as milliseconds or finer. The timestamp converter auto-detects the unit and tells you which one it assumed.
Are JWT exp and iat in seconds or milliseconds?
Seconds. RFC 7519 defines NumericDate as seconds since the epoch in UTC, ignoring leap seconds. A 13-digit exp is a bug on the issuer's side and should be rejected, not accepted.
Does Unix time include leap seconds?
No. POSIX counts every day as exactly 86,400 seconds, so leap seconds are not counted. That makes date arithmetic simple, but epoch time is not an exact count of elapsed physical seconds across a leap second.
How do I convert a Windows FILETIME to a Unix timestamp?
Subtract 116444736000000000 (the 1601→1970 offset in 100 ns ticks) and divide by 10,000,000. For example, FILETIME 133444736000000000 is Unix 1700000000, or 2023-11-14T22:13:20Z.
What happens on 19 January 2038?
At 2038-01-19T03:14:07Z, epoch time reaches 2147483647, the largest value a signed 32-bit integer can hold. Systems still using 32-bit time_t or 32-bit timestamp fields will overflow to a negative value (a date in 1901) unless they move to 64-bit time.