OG images in Rails
The fastest path for a Rails app is the signed GET /i/{keyId}/{sig}?...params URL: it renders on first request and serves from cache after that, so it can go straight
into a layout's <meta property="og:image"> with no call to
the ogmake API from your controller at request time.
1. Get a key
Sign in at /login and create a key in the dashboard. The signing secret
is shown once — store it with bin/rails credentials:edit (e.g.
under an ogmake.signing_secret key) rather than a plain
ENV var, so it's encrypted at rest in
config/credentials.yml.enc and read back with
Rails.application.credentials.dig(:ogmake, :signing_secret).
Still on a hand-issued beta key? Email support@ogmake.com.
2. Sign the URL in Ruby
The canonical string: drop sig and debug, sort remaining keys by UTF-16 code unit (matches the server, not Ruby's default
byte-order Array#sort), RFC3986-encode each key=value, join with &, then HMAC-SHA256 it with your key's signing
secret. The file below is the real, interpreter-run script (docs/snippets/sign.rb, run with ruby sign.rb) also used on the
signing page — it reproduces the same fixed test vector, so it
is provably correct rather than just plausible. A view helper or presenter does the same
two calls (canonical_query, sign_query) against your own params.
#!/usr/bin/env ruby
# docs/snippets/sign.rb — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
# Same fixed test vector as sign.mjs; run with `ruby sign.rb`, prints:
# format=png&template=basic&title=Hello%20World
# W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
require "openssl"
require "base64"
require "set"
# Your key's signing secret, base64 (shown once when the key is issued).
SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
PARAMS = { "template" => "basic", "title" => "Hello World", "format" => "png" }.freeze
UNRESERVED = /[A-Za-z0-9\-_.~]/.freeze
def rfc3986_encode(value)
value.to_s.b.each_byte.map do |byte|
char = byte.chr
UNRESERVED.match?(char) ? char : format("%%%02X", byte)
end.join
end
# Keys excluded from the canonical query (must match packages/core/src/signing.ts).
CANONICAL_EXCLUDED_KEYS = Set["sig", "debug"].freeze
# Sorts by UTF-16 code unit, the way JS's default string comparison sorts
# signing.ts's keys — NOT Ruby's `Array#sort`, which compares strings
# byte-by-byte (UTF-8 byte order). The two orders agree for BMP-only keys but
# diverge for an astral-plane key (e.g. an emoji, encoded in UTF-16 as a
# surrogate pair starting at 0xD800-0xDBFF) compared against a BMP
# private-use key (e.g. U+E000): UTF-16 order puts the astral key first,
# byte order puts it last. Using plain `.sort` here would build a different
# canonical string than the server and produce a bad signature for such keys.
def utf16_key(str)
units = []
str.each_codepoint do |cp|
if cp > 0xFFFF
cp -= 0x10000
units << (0xD800 + (cp >> 10))
units << (0xDC00 + (cp & 0x3FF))
else
units << cp
end
end
units
end
def canonical_query(params)
params.reject { |k, _| CANONICAL_EXCLUDED_KEYS.include?(k) }
.sort_by { |k, _| utf16_key(k) }
.map { |k, v| "#{rfc3986_encode(k)}=#{rfc3986_encode(v)}" }.join("&")
end
def base64url_encode(bytes)
Base64.strict_encode64(bytes).tr("+/", "-_").delete("=")
end
def sign_query(secret_base64, canonical)
secret = Base64.strict_decode64(secret_base64)
mac = OpenSSL::HMAC.digest("SHA256", secret, canonical)
base64url_encode(mac)
end
canonical = canonical_query(PARAMS)
puts canonical
puts sign_query(SIGNING_SECRET_BASE64, canonical)
3. Put it in the layout
Compute the signed URL in a helper and pull it into app/views/layouts/application.html.erb:
# app/helpers/og_image_helper.rb
module OgImageHelper
def og_image_url_for(post)
canonical = canonical_query(
"template" => "blog",
"site" => "example.com/blog",
"author" => post.author.name,
"date" => post.published_at.strftime("%b %-d, %Y"),
"title" => post.title,
)
secret = Rails.application.credentials.dig(:ogmake, :signing_secret)
sig = sign_query(secret, canonical)
key_id = Rails.application.credentials.dig(:ogmake, :key_id)
"https://ogmake.com/i/#{key_id}/#{sig}?#{canonical}"
end
end <%# app/views/layouts/application.html.erb %> <% if @post %> <meta property="og:image" content="<%= og_image_url_for(@post) %>"> <meta name="twitter:card" content="summary_large_image"> <% end %>
Gotcha: Rails' Content Security Policy blocks the image by default
Rails 6+ ships a CSP initializer (config/initializers/content_security_policy.rb) that's commented out by default but, once enabled, defaults to same-origin for
img-src. If your app has turned that on, the browser will
silently refuse to load an ogmake.com image — the tag renders,
the network request never completes, and there's no console error pointing at CSP unless you
have report-only mode wired up. Add the host explicitly:
policy.img_src :self, :https, "ogmake.com"
Turbo/Hotwire's async page loads don't change this — the CSP applies to the rendered response either way, so this is a one-time config fix, not something to special-case per view.
Verify it
Load the page and confirm the tag itself first:
curl -s https://your-app.com/posts/some-post | grep 'og:image'
Then check the image URL resolves without an error header — a failed signed render is still
a 200 fallback image, so curl -I the
URL and look for x-ogmake-error (see the
signing page's debugging section). Finally, run the
real production URL through Facebook's
Sharing Debugger or LinkedIn's Post
Inspector, and compose a new post on X with the URL to check the card there (X retired
its public Card Validator in 2022) — social
crawlers cache a URL's preview, so to force a fresh scrape, include a v cache-busting param (e.g. a version string) in the params you sign and re-run
sign_query. v is itself part of the
signed canonical string, so it has to go through canonical_query
like any other field — appending it to an already-signed URL just breaks the signature.