User avatar / photo upload
Profile photos done safely
A common identity feature: profile photos. Each user uploads an image. Looks simple, actually a security and ops challenge.
Threats
- Malicious uploads (malware, EICAR).
- Decompression bombs (zip-like images that explode RAM).
- SVG with embedded scripts (XSS).
- Tracking pixels (1x1 PNG with metadata).
- Adult / illegal content.
Server-side validation
import sharp from "sharp";
import fs from "fs/promises";
async function processAvatar(buffer: Buffer): Promise<Buffer> {
// Limit size BEFORE decompression
if (buffer.length > 5 * 1024 * 1024) throw new Error("too_large");
// Validate type by content, not extension
const type = await fileType.fromBuffer(buffer);
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (!type || !allowed.includes(type.mime)) throw new Error("invalid_type");
// Re-encode through Sharp (kills exploits in the source)
return sharp(buffer, { limitInputPixels: 24_000_000 }) // 24MP cap
.resize(512, 512, { fit: "cover" })
.webp({ quality: 80 })
.toBuffer();
}limitInputPixels: protection against decompression bombs.
Re-encoding: strips ALL metadata (EXIF, malicious code embedded).
No SVG
if (mime === "image/svg+xml") throw new Error("svg_not_allowed");SVGs are XML, can include <script>. Path of least resistance: don't allow SVG uploads. Use raster only.
For icons you need, ship pre-vetted SVGs in your app.
Storage
Don't store in DB. Use:
- Local filesystem (single host).
- S3 / R2 / Spaces (cloud).
- Image CDN (Cloudflare Images, Imgix).
ALTER TABLE identities
ADD COLUMN avatar_url TEXT; -- URL only, not bytesconst avatarKey = `avatars/${identityId}.webp`;
await s3.putObject({ Bucket: "olympus-avatars", Key: avatarKey, Body: processed });
const url = `https://cdn.your-domain.com/${avatarKey}`;
await kratos.adminPatch(identityId, [
{ op: "replace", path: "/traits/avatar_url", value: url }
]);Access control
Public bucket? Many apps make avatars publicly accessible. Anyone with the URL can view.
OK if:
- User explicitly chose to upload.
- Privacy policy says avatars are public.
NOT OK if:
- Internal-only app.
- Sensitive userbase (whistleblowers, etc.).
Private bucket → signed URLs:
const url = await s3.getSignedUrl("getObject", {
Bucket: "...",
Key: avatarKey,
Expires: 3600,
});Each viewer gets a time-limited URL.
Avatar URLs in tokens
Don't put avatar URLs in OAuth2 access tokens, they're long, change often, and aren't needed for authz.
In ID token: OK (OIDC's picture claim). It's short-lived.
In profile API: best (always-fresh).
Default avatars
When user hasn't uploaded:
function Avatar({ user }) {
if (user.traits.avatar_url) {
return <img src={user.traits.avatar_url} />;
}
return <DefaultAvatar name={user.traits.first_name} />;
}
function DefaultAvatar({ name }) {
const initial = name?.[0] ?? "?";
const bgColor = hashToColor(name);
return (
<div style={{ backgroundColor: bgColor }} className="avatar">
{initial}
</div>
);
}Generated initials. No image needed.
Update flow
<form onSubmit={uploadAvatar} encType="multipart/form-data">
<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp" />
<button>Upload</button>
</form>Server:
async function uploadAvatar(formData: FormData) {
const file = formData.get("avatar") as File;
const buffer = Buffer.from(await file.arrayBuffer());
const processed = await processAvatar(buffer);
const key = `avatars/${session.identity.id}-${Date.now()}.webp`;
await s3.putObject({ Bucket: "...", Key: key, Body: processed });
await kratos.adminPatch(session.identity.id, [
{ op: "replace", path: "/traits/avatar_url", value: `https://cdn.../$key` }
]);
return revalidatePath("/profile");
}Replace not delete
When user uploads new avatar:
- Don't delete old immediately (caches still serve it).
- Mark old as "to delete after 30 days."
- Garbage collect via cron.
INSERT INTO avatar_garbage (key, deleted_at)
VALUES ($old_key, NOW());
-- Cron daily:
SELECT key FROM avatar_garbage
WHERE deleted_at < NOW() - INTERVAL '30 days';
-- For each: s3.deleteObject + db.deleteContent moderation
If user-supplied avatars need moderation:
- AWS Rekognition, Azure Content Moderator: auto-classify.
- Hive Moderation: more aggressive.
- Self-host: harder.
Flag suspicious → human review. Don't auto-publish.
For most B2B SaaS: trust users; moderate only on report.
CDN integration
Each upload triggers CDN cache. Pre-warm if expected (e.g., new branding):
await fetch(avatarUrl); // CDN caches on first hitOr warm cache directly via CDN API.
Image variants
For different display sizes:
const sizes = [32, 64, 128, 256, 512];
const variants = await Promise.all(sizes.map(size =>
sharp(buffer).resize(size, size, { fit: "cover" }).webp().toBuffer()
));
for (const [i, variant] of variants.entries()) {
await s3.put(`avatars/${id}-${sizes[i]}.webp`, variant);
}Serve right size per use:
<picture>
<source srcset={`${cdn}/${id}-32.webp 1x, ${cdn}/${id}-64.webp 2x`} media="(max-width: 100px)" />
<source srcset={`${cdn}/${id}-128.webp 1x, ${cdn}/${id}-256.webp 2x`} />
<img src={`${cdn}/${id}-256.webp`} />
</picture>Or use an image CDN that resizes on-the-fly (Imgix, Cloudflare Images, Cloudinary):
<img src={`${imgcdn}/${avatarKey}?w=128&h=128&fit=cover`} />Cost
- Storage: ~$0.02/GB. 100k users with 50KB avatars = 5GB = $0.10/mo.
- Bandwidth: depends. CDN with cache cuts cost significantly.
Trivial for typical apps.