S3 Presigned URL Expiration: Why URLs Die Early (and Fix)

S3 presigned URL expiration is capped by your signing credentials, not the expiry you set. Learn why ExpiredToken hits and the PHP fix that works.

TL;DR

  • What: A presigned URL dies when the credentials that signed it die, whatever expiry you requested.
  • Why it matters: Lambda, ECS and EC2 roles use temporary credentials, so links silently break with ExpiredToken.
  • What to do: Keep signed links under 15 minutes, or sign with IAM user keys or CloudFront for longer.
  • Key stat: IAM user keys allow 7 days (604,800 seconds). Role-signed URLs end when the session ends.

S3 presigned URL expiration is capped by the signing credentials, not by the expiry you pass. A URL signed with a role or STS token stops working when that token expires, often within 1 to 12 hours. Only IAM user keys allow the full 7 days under Signature Version 4.

An S3 presigned URL is a time-limited link that embeds a SigV4 signature, an access key identifier, and an expiry in its query string. It grants the signer’s permissions for one operation on one object. S3 validates the signature and the signer’s credential lifetime on every request, not at creation time.

S3 presigned URL expiration is the most common reason a “7 day” download link fails on day one. I have seen this bug in file-sharing features, invoice emails, and export jobs. The code looks correct, the expiry argument looks correct, and yet S3 answers with ExpiredToken hours later. This guide explains why it happens, shows the PHP fix, and compares four ways to get long-lived links safely.

Why Does S3 Presigned URL Expiration Ignore the Time I Set?

The URL expires when the signing credentials expire, even if you requested a later time. AWS documents this directly: a URL signed with a temporary token stops working when that token does.

A presigned URL carries two clocks. The first is the X-Amz-Expires value you chose. The second is the lifetime of the access key that signed it. S3 enforces whichever ends first.

Long-term IAM user keys have no second clock. Temporary credentials from a Lambda execution role, an ECS task role, or an EC2 instance profile do. The S3 user guide on presigned URLs states that EC2 role credentials are typically valid for about 6 hours. Role-based URLs therefore cannot outlive that window.

When I first hit this, the symptom was confusing. Links worked in local development, where I used static keys. They failed in production, where the app ran on a role. That mismatch is the signature of this bug.

What does the error look like?

S3 returns HTTP 400 with ExpiredToken and the message “The provided token has expired.” Note that it is not a 403 AccessDenied. If you see Request has expired instead, the URL’s own X-Amz-Expires ran out, which is a different problem.

How Long Does an S3 Presigned URL Last for Each Credential Type?

The maximum lifetime depends entirely on who signed it. IAM user keys allow up to 7 days with SigV4. Everything temporary is shorter.

The numbers below come from the AWS documentation and the AWS re:Post knowledge center.

  • IAM user access keys: up to 7 days, which is 604,800 seconds.
  • EC2 instance profile role: typically about 6 hours.
  • Assumed role through the SDK or CLI: 1 hour by default, up to 12 hours (43,200 seconds) if the role allows it.
  • Role chaining: capped at 1 hour, regardless of the role’s maximum session setting.
  • S3 console: 1 minute to 12 hours.

Lambda reports vary. An AWS re:Post answer says Lambda role URLs last 1 hour. A Laracasts thread on Laravel Vapor reports links dying after about 6 hours. Platforms rotate credentials on their own schedule. Design around a safe floor of 15 minutes.

The IAM role concepts page confirms that role chaining limits a session to a maximum of one hour. If your Lambda assumes another role before signing, your ceiling is 3,600 seconds.

How Do You Fix S3 Presigned URL Expiration in PHP?

Clamp the requested expiry to the credential’s remaining lifetime before you sign. The AWS SDK for PHP will happily sign a 7-day URL with a 1-hour token and never warn you.

Here is the broken pattern, taken from the standard SDK example.

// BROKEN on a role: asks for 7 days, gets at most the token lifetime
$cmd = $s3->getCommand('GetObject', [
    'Bucket' => 'my-exports',
    'Key'    => 'invoices/2026-10.pdf',
]);
$request = $s3->createPresignedRequest($cmd, '+7 days');
$url = (string) $request->getUri();

The AWS SDK for PHP presigned URL guide shows this exact call shape with a '+1 hour' string. Nothing in it checks the credential.

Follow these steps to add a guard.

  1. Resolve the current credentials with $s3->getCredentials()->wait().
  2. Read getExpiration(). It returns a Unix timestamp, or null for credentials that never expire.
  3. Subtract a 60-second safety margin from the remaining time.
  4. Use the smaller of the requested expiry and that remaining time.
  5. Log a warning whenever you clamp, so the bug is visible.
function safePresign(S3Client $s3, string $bucket, string $key, int $wantSeconds): string
{
    $creds = $s3->getCredentials()->wait();
    $exp   = $creds->getExpiration(); // int|null (Unix timestamp)

    $seconds = $wantSeconds;
    if ($exp !== null) {
        $remaining = $exp - time() - 60;
        if ($remaining < $wantSeconds) {
            error_log("presign clamped: wanted {$wantSeconds}s, credentials allow {$remaining}s");
            $seconds = max($remaining, 60);
        }
    }

    $cmd = $s3->getCommand('GetObject', ['Bucket' => $bucket, 'Key' => $key]);
    $req = $s3->createPresignedRequest($cmd, '+' . $seconds . ' seconds');
    return (string) $req->getUri();
}

The getExpiration() method is documented as int|null in the SDK API reference. A null return means the credentials do not expire, which is the case for static IAM user keys. I recommend logging every clamp. In my experience, the log line is what finally makes the team notice the design flaw.

What about Laravel’s temporaryUrl?

Laravel’s Storage::temporaryUrl() wraps the same call and has the same trap. The Vapor thread mentioned above used now()->addDay() and got ExpiredToken after about 6 hours. Wrap it in the same guard, or shorten the expiry.

Why Shorter Links Are the Real Fix for S3 Presigned URL Expiration

The best fix is not a longer URL but a shorter one generated on demand. A link signed for 900 seconds (15 minutes) is safe on every AWS compute platform I know of. It also limits damage if the link leaks.

The advancedweb.hu write-up on ExpiredToken gives the same advice. Make each signature expire before its credentials. Use 15 minutes or less for a Lambda signer.

For emailed links, use a redirect endpoint. Put a stable URL in the email, such as /download/abc123. When the user clicks, your app checks their session or token, then answers with a 302 redirect to a fresh 5-minute presigned URL. The email link can then live for weeks, while the S3 link lives for minutes.

Pair this with a retry-safe design. If the user double-clicks or the browser retries, each click makes a new signed URL, which is harmless. I covered the same retry thinking in the idempotency keys guide for PHP developers.

Signing itself is free. The SDK computes the signature locally, so there is no API call to bill. You pay only when S3 serves the request, and GET requests in S3 Standard are listed at $0.0004 per 1,000 on the S3 pricing page. A redirect endpoint adds no AWS cost, only one small app request.

How Do You Get a Presigned URL That Lasts 7 Days?

Sign with long-term IAM user access keys, which allow up to 7 days under SigV4. This is the only way to reach 604,800 seconds with a presigned URL, and AWS staff on re:Post call it a poor design for good reason.

If you must do it, follow these rules.

  1. Create a dedicated IAM user used only for signing.
  2. Grant it s3:GetObject on one bucket prefix and nothing else.
  3. Store the keys in Secrets Manager, not in plain environment variables.
  4. Build a separate S3 client with those keys, so the rest of the app keeps using its role.
  5. Rotate the keys on a schedule, since revoking them kills every outstanding URL.

That last point matters. AWS states a URL also stops working if the signing credential is revoked, deleted, or deactivated. A key rotation will therefore invalidate every long-lived link you issued with it.

For a broader view of where this fits in a serverless design, see the server versus serverless architecture comparison.

When Should You Use CloudFront Signed URLs Instead?

Use CloudFront signed URLs when you need links valid for weeks, months, or years without storing IAM user keys. CloudFront signs with an RSA 2048 or ECDSA 256 key pair that you control, so expiry is independent of any AWS session.

The CloudFront signed URL documentation says URLs can be valid for as little as a few minutes or possibly for years. It also notes that adding a query string after signing causes an HTTP 403.

Setup has more moving parts. Create a key group and upload a public key. Attach the group to a cache behavior. Lock the bucket down with origin access control. In return you get caching, custom policies, and optional IP restrictions.

The private key lives in your app, which has the same custody problem as IAM user keys. The difference is blast radius. A leaked CloudFront private key cannot read S3 directly, and you can remove the key from the key group to revoke it.

How do the four options compare for S3 presigned URL expiration?

Four ways to issue a download link and how long each can last
Option Max lifetime Complexity When to use
Presigned URL from a role (Lambda, ECS, EC2) About 1 to 12 hours, session-bound Low Short downloads, uploads, anything under 15 minutes
Redirect endpoint plus a fresh 5-minute presigned URL Unlimited for the stable link Low to medium Emailed links and dashboards that must stay valid
Presigned URL from IAM user keys 7 days (604,800 seconds) Medium, key rotation needed Legacy systems that cannot add an endpoint
CloudFront signed URL with a key group Minutes to years High Media delivery, public-scale downloads, long-lived private links

The NexGismo verdict: Stop fighting S3 presigned URL expiration by asking for longer URLs. Put a stable URL in your emails, authorize the click in your own app, and redirect to a 5-minute presigned URL. Use CloudFront signed URLs only when you serve media at scale. Treat IAM user keys for signing as a last resort.

  • S3 presigned URL expiration is bounded by the signing credentials, even if the requested expiry is later.
  • IAM user keys allow a maximum of 7 days (604,800 seconds) for a SigV4 presigned URL.
  • Role chaining caps any assumed-role session, and any URL it signs, at 1 hour.
  • EC2 instance profile credentials last about 6 hours, so URLs signed on EC2 cannot exceed that.
  • S3 returns ExpiredToken, not AccessDenied, when the signing token has expired.
  • Signing a URL is a local computation with no AWS charge, and S3 Standard GET requests cost $0.0004 per 1,000.

Frequently Asked Questions

How long does an S3 presigned URL last?

S3 presigned URL expiration is 7 days at most, and only when signed with IAM user keys under Signature Version 4. When signed with temporary credentials from a role, it lasts only until those credentials expire, typically between 1 and 12 hours. The S3 console allows 1 minute to 12 hours.

Why is my S3 presigned URL expiring early?

Your presigned URL is expiring early because the credentials that signed it expired first. Roles used by Lambda, ECS, and EC2 issue temporary tokens. S3 rejects the URL with an ExpiredToken error once the token ends, regardless of the longer expiry you requested.

Can I extend an S3 presigned URL after it is created?

No. The expiry is part of the signed query string, so you cannot extend an existing presigned URL. Changing it invalidates the signature. You must generate a new URL. A redirect endpoint in your app can generate a fresh one on every click.

Lambda and security questions

What is the maximum expiry for a presigned URL in Lambda?

With the Lambda execution role, the practical maximum is the role session length, which AWS re:Post answers put at about 1 hour. To reach 7 days, sign with long-term IAM user keys stored in Secrets Manager. Keeping links under 15 minutes is the safest design.

Is a presigned URL secure?

A presigned URL is secure only as long as it stays private, because anyone holding it gets the signer’s permissions for that object. Keep expiry short, scope the signer’s IAM policy to one prefix, and avoid logging full URLs. Revoking the signing credential invalidates every URL it signed.

S3 presigned URL expiration looks like a simple timeout, but it is really a question of who signed the link. Roles give you safety and short lives. IAM user keys give you length and risk. My rule is simple: sign short, sign often, and never promise a user a long-lived S3 link. Put a stable endpoint in front, let your app decide who gets in, and let S3 hand out five-minute keys. If you need links that last for months, move to CloudFront and a key group. Check your own code today for any call that asks for more than an hour, because that is where this bug is hiding.