Skip to main content
Back to Blog
API Testing
2026-07-13

Testing Signed-URL Expiration in an API

Learn signed-URL expiration API testing with controlled clocks, boundary assertions, replay checks, and signature tampering that expose real authorization flaws.

Testing Signed-URL Expiration in an API

At 12:00:30, a download link works. At 12:00:31, the same bytes, path, and query string must fail. That one-second transition is where signed-URL implementations reveal mismatched clocks, inclusive-boundary mistakes, cache leaks, and authorization shortcuts. A happy-path test that opens the link immediately proves little. The useful suite controls time and treats the URL as a bearer credential with a deliberately short life.

This guide covers an application-owned signing API, not one vendor's storage implementation. The examples use Node.js, Vitest, and an injected clock so the expiry contract can be tested without sleeping. The same test design applies when the URL ultimately points to S3, a CDN, an image transformer, or a private application route.

Write down the credential contract before testing it

A signed URL normally binds a resource identifier, an expiry instant, and a cryptographic signature. Mature designs may also bind the HTTP method, content digest, tenant, key version, or response-disposition options. The verifier reconstructs the canonical input and compares a freshly calculated message authentication code with the supplied signature.

The contract needs answers that product prose often omits:

DecisionExample contractFailure exposed when unspecified
Expiry comparisonValid only while current time is strictly less than expiresAtLink remains usable for an extra tick
Timestamp unitUnix seconds, base 10Millisecond values create decades-long links
Bound fieldsMethod, path, expiry, tenantA GET token authorizes DELETE or crosses tenants
Maximum lifetime15 minutes from issuanceCaller requests an effectively permanent credential
Canonical encodingRFC 3986 path bytes, normalized onceEquivalent-looking paths verify differently
Failure response403 with stable machine codeClients scrape human text to decide whether to refresh
Key rotationCurrent and retiring key IDs acceptedRotation invalidates every active download

Do not infer those choices from implementation details. Put them in the API specification and make the tests executable interpretations of that policy. If the API accepts an expiry supplied by a caller, test both the requested duration and the server-side cap. If the API chooses expiry itself, assert the returned instant relative to the server's controlled clock.

Signed URLs are bearer credentials. Anyone possessing one may be able to use it, so logs, analytics, referrer headers, and screenshots are part of the threat surface. The authentication and authorization testing guide provides the broader access-control context. Here, the focus stays on the credential's temporal and cryptographic boundaries.

Make time a dependency, not a delay

Sleeping until a URL expires creates a slow test with a race around the scheduler. It also leaves long-lived production configurations largely untested. Inject a clock into both issuer and verifier. Production supplies the real clock; tests supply a mutable clock.

The following small implementation shows the shape. It signs method, path, tenant, expiry, and key ID with HMAC-SHA256. The verifier rejects malformed timestamps and performs a timing-safe signature comparison.

import { createHmac, timingSafeEqual } from 'node:crypto';

type Clock = { nowMs(): number };
type Keyring = Record<string, Buffer>;

export function createSigner(clock: Clock, keys: Keyring, activeKeyId: string) {
  const mac = (value: string, keyId: string) =>
    createHmac('sha256', keys[keyId]).update(value).digest('base64url');

  return {
    issue(path: string, tenant: string, ttlSeconds: number) {
      if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 900) {
        throw new RangeError('ttlSeconds must be between 1 and 900');
      }
      const exp = Math.floor(clock.nowMs() / 1000) + ttlSeconds;
      const method = 'GET';
      const canonical = [method, path, tenant, exp, activeKeyId].join('\n');
      const query = new URLSearchParams({
        tenant,
        exp: String(exp),
        kid: activeKeyId,
        sig: mac(canonical, activeKeyId),
      });
      return path + '?' + query.toString();
    },

    verify(rawUrl: string, method = 'GET') {
      const url = new URL(rawUrl, 'https://files.example.test');
      const tenant = url.searchParams.get('tenant');
      const kid = url.searchParams.get('kid');
      const sig = url.searchParams.get('sig');
      const rawExp = url.searchParams.get('exp');
      if (!tenant || !kid || !sig || !rawExp || !/^\d+$/.test(rawExp) || !keys[kid]) {
        return { ok: false, reason: 'invalid_link' } as const;
      }
      const exp = Number(rawExp);
      if (!Number.isSafeInteger(exp) || Math.floor(clock.nowMs() / 1000) >= exp) {
        return { ok: false, reason: 'expired' } as const;
      }
      const canonical = [method, url.pathname, tenant, exp, kid].join('\n');
      const expected = Buffer.from(mac(canonical, kid));
      const supplied = Buffer.from(sig);
      if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) {
        return { ok: false, reason: 'bad_signature' } as const;
      }
      return { ok: true, tenant, path: url.pathname } as const;
    },
  };
}

In a real service, avoid returning distinctions that help an attacker enumerate signature validity unless the client genuinely needs them. Tests can assert internal reason codes at a unit boundary while the public API maps malformed, expired, and invalid signatures to the same safe response.

Pin the exact expiration edge

Suppose issuance happens at 1,700,000,000,250 milliseconds and the TTL is 30 seconds. Because the contract uses Unix seconds, expiry is 1,700,000,030. Decide what happens immediately before, exactly at, and immediately after that value. The implementation above uses now >= exp as expired, so equality fails.

import { describe, expect, it } from 'vitest';
import { createSigner } from './signed-url';

describe('signed URL expiration boundary', () => {
  it('accepts the last second and rejects equality with exp', () => {
    let now = 1_700_000_000_250;
    const clock = { nowMs: () => now };
    const signer = createSigner(clock, { k1: Buffer.from('test-secret-32-bytes-long-value!') }, 'k1');
    const url = signer.issue('/reports/quarter-4.pdf', 'tenant-a', 30);
    const exp = Number(new URL(url, 'https://files.test').searchParams.get('exp'));

    now = (exp - 1) * 1000 + 999;
    expect(signer.verify(url)).toMatchObject({ ok: true });

    now = exp * 1000;
    expect(signer.verify(url)).toEqual({ ok: false, reason: 'expired' });

    now = (exp + 1) * 1000;
    expect(signer.verify(url)).toEqual({ ok: false, reason: 'expired' });
  });

  it('caps caller-selected lifetimes', () => {
    const signer = createSigner(
      { nowMs: () => 1_700_000_000_000 },
      { k1: Buffer.from('test-secret-32-bytes-long-value!') },
      'k1',
    );
    expect(() => signer.issue('/a', 'tenant-a', 901)).toThrow(RangeError);
    expect(() => signer.issue('/a', 'tenant-a', 0)).toThrow(RangeError);
    expect(() => signer.issue('/a', 'tenant-a', 1.5)).toThrow(RangeError);
  });
});

This test has no wall-clock delay and no tolerance window. That precision is the point. A separate end-to-end test can cover clock propagation through the HTTP stack, but it should not replace deterministic boundary tests.

Include issuance near a second rollover. If the issuer rounds up while the verifier rounds down, a nominal 30-second URL may live almost 31 seconds. That might be acceptable, but it must be intentional. Also test negative timestamps, leading plus signs, exponent notation, whitespace, extremely long digit strings, and values larger than JavaScript's safe integer. Timestamp parsing is input validation, not bookkeeping.

Tamper one signed component at a time

An invalid signature test that replaces the whole URL is weak because it does not reveal what the signature actually binds. Start with a legitimate URL, mutate one component, preserve all others, and expect rejection.

MutationWhat a rejection provesDangerous result
Change /reports/a.pdf to /reports/b.pdfResource path is boundHorizontal object access
Replace tenant A with tenant BTenant context is boundCross-tenant data disclosure
Increase exp by 3,600Lifetime is boundClient can extend its own credential
Send POST instead of GETHTTP method is boundRead credential becomes a write credential
Replace kidKey selection is part of verificationAlgorithm or key confusion
Duplicate exp parameterParser has an explicit duplicate policySigner and proxy select different values
Change percent encoding of pathCanonicalization is consistentAlternate representation bypasses MAC

The duplicate-query case deserves special attention. Frameworks disagree on whether ?exp=old&exp=new returns the first value, last value, or array. A CDN, WAF, application router, and signature library may not agree. The safest verifier rejects duplicates for every security-sensitive parameter. Test the actual deployed chain where possible.

Path normalization has similar traps: encoded slashes, dot segments, repeated slashes, Unicode normalization, and case sensitivity. Sign the exact representation the origin authorizes. If an intermediary rewrites the path after verification, ensure the verified resource identity survives the rewrite unchanged.

Exercise replay without confusing it with expiration

Most signed download URLs are intentionally replayable during their validity window. Two successful GETs before expiry are therefore not automatically a vulnerability. The test should reflect the product contract:

  • A reusable download link should work repeatedly until expiration, then fail consistently.
  • A one-time export link needs server-side state, such as a nonce consumed atomically on first use.
  • A presigned upload may allow repeated PUT requests that overwrite the same object unless storage policy prevents it.
  • A link tied to a transaction should reject a replay even if its timestamp remains valid.

For a one-time design, run two requests concurrently with the same nonce. Exactly one should consume it. A read-then-delete implementation can let both through; an atomic insert or compare-and-set is needed. Then retry after a simulated timeout. If the client did not receive the first response, the API must have a documented result for the second attempt.

Do not label every replay as a cryptographic failure. HMAC proves integrity and authenticity of the signed fields. It does not provide uniqueness. Replay resistance comes from short lifetimes, single-use state, audience binding, or a protocol with a fresh challenge.

Separate clock skew policy from accidental grace

Distributed systems rarely agree on the exact time. A signer may run ahead of the verifier, or a storage provider may evaluate a URL on a different host. The response should be a deliberate skew policy, not a broad test tolerance that hides bugs.

For expiration-only credentials, accepting requests for 30 seconds after exp weakens the advertised lifetime by 30 seconds. Another design includes both nbf (not before) and exp, allowing a small skew when checking both bounds. Whatever policy you choose, test the maximum accepted and first rejected instants on each side.

Monitor clock synchronization in production. If hosts drift beyond policy, fail closed and alert, or route issuance and verification through a consistent authority. Merely increasing the grace interval trades an operational problem for a security exposure.

Add a test for a verifier whose clock is deliberately ahead and another whose clock is behind. These are not excuses to change expected results dynamically. They demonstrate the consequences of the declared skew allowance. When the signer returns server time alongside the link, assert that clients treat it as diagnostic information, not as permission to rewrite the signed expiry. Leap seconds are normally absorbed by Unix-time handling, but clock steps and virtual-machine suspension can still expose assumptions about monotonic progression.

Test key rotation while links remain active

Rotation creates a temporal overlap between keys. Issue a URL with k1, switch issuance to k2, and verify that the k1 URL remains valid only until its natural expiry. New URLs must carry k2. Once no legitimate k1 URL can remain active, remove the old key and ensure its URLs fail.

Also test an unknown key identifier, an empty identifier, and a valid identifier paired with a signature from another key. Never let a request choose a raw key location or signing algorithm. The identifier should select from a server-controlled allowlist.

Emergency revocation is different from routine rotation. Stateless signed URLs cannot normally be revoked individually. Options include very short TTLs, a denylist keyed by nonce, a resource version embedded in the signature, or a signing-key revocation that invalidates a whole cohort. Each changes availability and storage costs. Your incident plan should match the test plan.

Verify behavior through caches and redirects

The origin can reject an expired URL correctly while a cache continues serving a previously successful response. Test a request before expiry to warm the CDN, advance time, and repeat the identical request. Private responses should use appropriate cache controls, and the cache key must include the security-relevant query string if caching is allowed at all.

Redirect flows add another hop. If the API validates a short application URL and redirects to a longer storage URL, capture both expiries. The downstream credential should not outlive the policy the caller was promised without a documented reason. Ensure the Location value is not logged or exposed to an unintended origin.

For browser downloads, check referrer policy and cross-origin behavior. For uploads, verify content length, content type, checksum, and object key if those are intended restrictions. A valid signature around an under-specified upload can still let a caller store executable content or overwrite another object.

The API security testing checklist is useful for the surrounding transport, logging, rate-limit, and data-exposure checks. Keep the temporal suite focused enough that a failure identifies the broken invariant.

Build a failure matrix the team can operate

Public responses should be stable and safe. Many services return 403 for expired and invalid signatures to avoid leaking validation detail. Others return a machine-readable code so trusted clients can request a fresh URL. Either can work if the status, body, cache headers, and telemetry are consistent.

Record structured server-side reasons without recording the full URL. Useful fields include key ID, route template, verifier result, clock offset, and a hash of a nonce. Redact the signature and sensitive query values. An alert on a surge of expired links suggests UX or clock trouble; a surge of bad signatures may indicate tampering or a canonicalization regression.

Add a log-capture test that submits a URL containing a recognizable canary signature and tenant value, then scans application and access logs. The canary must not appear verbatim. Also inspect tracing attributes and exception messages, since redaction in the main logger does not protect every telemetry path. Preserve a safe request correlation ID so an operator can still connect the rejection to verifier diagnostics.

Repeat that check for redirect responses and client-visible error bodies. A credential removed from application logs can still escape through a reverse proxy's request-target field or an analytics event recorded before redaction.

Run fast deterministic tests on every change. Keep a smaller deployed-path suite for CDN behavior, proxy parsing, key distribution, and real storage-provider semantics. Provider tests should use short-lived isolated resources and tolerate network duration without weakening the actual expiration assertion.

Frequently Asked Questions

Should an expired signed URL return 401 or 403?

Usually 403 is clearer because the server understood the bearer credential but refuses the requested operation. A 401 response is associated with authentication challenges and may prompt unsuitable client behavior. Consistency with the API's documented security model matters more than hiding behind a generic 400.

Is it safe to allow a few seconds after the expiry timestamp?

Only if that grace period is part of the stated policy and threat model. Test it as an explicit interval. A hidden tolerance means every link lives longer than callers believe and can mask clock synchronization failures.

Can I test expiration by setting a one-second TTL and sleeping?

Keep at most one such end-to-end smoke test. Unit and component tests should inject time and assert the exact boundary. Sleep-based suites are slower, race-prone, and unable to cover long production TTLs efficiently.

How do I revoke one signed URL before it expires?

A purely stateless MAC cannot revoke one instance. Include a nonce or resource version and consult server-side state, shorten the lifetime, or rotate a key to invalidate a larger set. Test concurrency if a nonce is single-use.

What is the highest-value tampering test?

Change the authorized resource identifier while preserving the original signature. That test quickly reveals whether the verifier actually binds the path or object key. Follow it with tenant, expiry, method, duplicate-parameter, and encoding mutations because each targets a different canonicalization assumption.

Testing Signed-URL Expiration in an API | QASkills.sh