YAML.safe_load: Safely Deserialize YAML in Ruby
YAML is Ruby’s go-to format for configuration files, data exchange, and serialization. Rails uses it, Bundler uses it, Docker Compose uses it. The standard library YAML module handles parsing and generating YAML. But loading raw YAML is dangerous, so YAML.safe_load lets you safely deserialize untrusted YAML without risking remote code execution. This guide covers how safe_load works, what it protects against, and how to use it correctly.
Intro context
Safe YAML loading matters because configuration files often travel through many hands before they reach your code. A file may start as a trusted app setting, then get copied into a deployment pipeline, then end up fed into a parser from a completely different source. Once that happens, the parser has to assume the content might be hostile instead of friendly.
That is where safe_load earns its place. It gives you a clear boundary between plain data and Ruby objects, which helps keep deserialization predictable. If you already know Struct or Ruby symbols, the important idea is that YAML can recreate those richer types only when you explicitly allow them.
Why YAML.load is dangerous
YAML has tags that represent Ruby objects. The !ruby/object tag tells the parser to instantiate any Ruby class:
malicious_yaml = <<~YAML
--- !ruby/object:OpenWithShellExpansion
cmd: "echo pwned > /tmp/pwned.txt"
YAML
YAML.load(malicious_yaml)
# Runs the shell command. You've been owned.
This attack is not hypothetical. Public CVEs have documented YAML deserialization exploits in Ruby applications going back years. The !ruby/object tag is the mechanism: it tells the parser to reconstruct a Ruby object from the YAML representation, which means the attacker controls both the class and the constructor arguments.
YAML.load can instantiate any Ruby object, not just “safe” data types. This is a well-known deserialization vulnerability class. Real-world exploits have compromised servers through YAML loading. If an attacker can feed a YAML string into your application, they can execute arbitrary code.
That risk is why the safe version exists at all. The parser itself is not broken, but unrestricted object loading means the input controls more than just data values. When the source is untrusted, you want the parser to stop at plain data instead of constructing arbitrary objects on your behalf.
YAML.safe_load: the safe alternative
YAML.safe_load restricts which classes can be deserialized. It raises Psych::DisallowedClass for any tag not on an explicit whitelist.
YAML.safe_load(malicious_yaml)
# => Psych::DisallowedClass: Tried to load unspecified class: OpenWithShellExpansion
The attacker’s payload is neutralized, so the program crashes safely instead of running their code. The exception is a Psych::DisallowedClass, which you can catch and handle gracefully rather than letting it propagate as an unhandled error.
The important part is not that the parse fails, but that it fails in a controlled way. A controlled failure gives you a chance to log the issue, reject the request, or fall back to a safer path without ever executing the payload.
Basic types are always allowed regardless of permitted_classes: String, Integer, Float, TrueClass, FalseClass, NilClass, Array, Hash.
The safe_load API
YAML.safe_load(yaml, permitted_classes: [], permitted_symbols: [], aliases: false, date: nil, permitted_regexps: [])
All parameters are keyword arguments.
The defaults are intentionally conservative. That makes safe_load slightly more annoying when you need non-plain data, but it also means the method is safe to use by default in code that accepts YAML from outside your trust boundary.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
permitted_classes | Array of Class/String | [] (Ruby 3.1+), [Symbol, Date] (Ruby 3.0) | Extra classes to allow |
permitted_symbols | Array of Symbol | [] | Bare symbols to allow |
aliases | Boolean | false (Ruby 3.1+), true (Ruby 3.0) | Whether to resolve &anchor / *alias |
date | Class or nil | nil (Ruby 3.1+), Date (Ruby 3.0) | Class to parse bare dates as |
permitted_regexps | Array of Regexp | [] (Ruby 3.2+) | Allowed !ruby/regexp tags |
permitted_classes: whitelisting safe classes
You often need to load custom classes you serialized yourself. Use permitted_classes to explicitly allow them:
yaml_with_dates = <<~YAML
created: 2023-01-15
updated: 2023-06-20
YAML
# Without permission, dates come back as strings
YAML.safe_load(yaml_with_dates)
# => {"created"=>"2023-01-15", "updated"=>"2023-06-20"}
# With Date permitted, they become Date objects
YAML.safe_load(yaml_with_dates, permitted_classes: [Date])
# => {"created"=>#<Date: 2023-01-15>, "updated"=>#<Date: 2023-06-20>}
With Date permitted, the parser reconstructs real Date objects instead of leaving them as plain strings. That means you can call date methods on the result without an extra parsing step. The tradeoff is that each permitted class expands the attack surface slightly, so you should only whitelist the classes your configuration format genuinely needs.
Common values for permitted_classes:
| Class | Use case |
|---|---|
Date | Config files with date values |
Time | Timestamps |
Symbol | Hash keys serialized as symbols |
Regexp | Ruby 3.2+: !ruby/regexp tag |
Never add classes you don’t need. Each permitted class is a potential attack surface.
When you do need a class, keep the whitelist as small as possible and document why it exists. A short comment beside the call site usually helps future readers understand that the permission was deliberate rather than accidental.
The whitelist pattern scales best when the application keeps a single, reviewed list of permitted classes rather than letting every call site add new ones independently. That centralised list is easier to audit and less likely to grow unchecked over time.
Ruby 3.1: the breaking change
Ruby 3.1 changed all safe_load defaults to be more restrictive. This broke many applications on upgrade.
What changed:
permitted_classesdefault changed from[Symbol, Date]to[], no custom classes allowed by defaultaliasesdefault changed fromtruetofalse, YAML aliases must be explicitly enableddatedefault changed fromDatetonil, bare dates return strings, not Date objects
# Ruby 3.0 — Date allowed automatically
YAML.safe_load("--- 2023-01-15")
# => #<Date: 2023-01-15>
# Ruby 3.1+ — Date requires explicit permission
YAML.safe_load("--- 2023-01-15", permitted_classes: [Date])
# => #<Date: 2023-01-15>
If you’re upgrading to Ruby 3.1+ and seeing Psych::DisallowedClass errors on Date objects, add permitted_classes: [Date] to your calls. The upgrade path is usually to audit each safe_load call and be explicit about the classes it should accept.
That upgrade pain is common because the older defaults were more permissive. The fix is usually straightforward once you know which classes your YAML actually needs, but it is worth checking each call site instead of enabling everything globally.
Aliases and DoS protection
YAML aliases let you reference the same object without duplicating it:
yaml_alias = <<~YAML
defaults: &defaults
timeout: 30
retries: 3
production:
<<: *defaults
timeout: 60
YAML
Aliases look useful, but they enable DoS attacks. Attackers can craft YAML with exponential alias expansion, causing the parser to consume huge amounts of CPU and memory:
The mechanism behind the attack is straightforward: YAML anchors and aliases let you reference the same node from multiple places, and nested aliases can force the parser to expand the same structure exponentially. A payload of a few hundred bytes can balloon into gigabytes of in-memory objects before the parser finishes, tying up CPU and eventually triggering an out-of-memory kill from the operating system. Because the attack happens during parsing rather than after, input size limits alone do not protect you---the expansion ratio is what matters.
# Malicious YAML that creates exponential parsing time
# (simplified example — real exploits are more sophisticated)
evil = "--- &a [" * 1000 + "]" * 1000
YAML.safe_load(evil, aliases: true) # Could hang your server
Ruby 3.1+ disables aliases by default. If you need them, explicitly enable them only for trusted YAML:
The decision to enable aliases should always be made at the call site rather than in a global configuration, because aliases introduce an entire class of parser-level attacks that no amount of application-level validation can fully mitigate. Most applications never need aliases at all; they are mainly useful for DRY configuration files where repeating the same default block would make the file harder to maintain. If you do enable them, keep the scope as small as possible---ideally a single safe_load call for a single known file path.
YAML.safe_load(trusted_yaml, aliases: true)
For any untrusted source, keep aliases disabled.
If you have a trusted configuration file that depends on aliases, enable them only for that file or that code path. The narrower the permission, the easier it is to reason about the failure modes.
Common errors
Psych::DisallowedClass
Raised when the YAML contains a tag for a class not in permitted_classes:
YAML.safe_load("--- !ruby/object:OpenWithShellExpansion\n cmd: id")
# => Psych::DisallowedClass: Tried to load unspecified class: OpenWithShellExpansion
Fix: either remove the untrusted YAML source, or add the class to permitted_classes if you trust it.
When you see this error, the right fix depends on the source. For untrusted input, the correct answer is usually to reject it. For your own application files, the right answer may be to whitelist exactly the classes you know you serialized.
Symbol handling in Ruby 3.1+
Ruby 3.1+ doesn’t allow bare symbols by default. If your YAML has symbol keys and you’re upgrading from Ruby 3.0:
# Symbol keys as hash values work fine (strings are used)
YAML.safe_load("{ foo: 1, bar: 2 }")
# => {"foo"=>1, "bar"=>2}
# But !ruby/symbol tag needs explicit permission
YAML.safe_load("--- !ruby/symbol\nfoo", permitted_symbols: [:foo])
# => {:foo}
With the common error cases covered, you are ready to put safe_load to work in everyday Ruby code. The patterns below cover the three most frequent scenarios you will encounter: loading a trusted application config file, handling YAML from an untrusted user, and inspecting unknown YAML before deciding whether to parse it. Each pattern builds on the principles from the earlier sections, and together they form a complete strategy for safe YAML handling in any Ruby project.
Practical patterns
Loading application config
require 'yaml'
def load_config(path)
yaml_content = File.read(path)
YAML.safe_load(yaml_content, permitted_classes: [Date, Symbol])
end
config = load_config('/etc/myapp/config.yml')
The load_config wrapper above works well for files you control, but it still assumes the YAML is trustworthy. When the input comes from outside your application---user submissions, webhook payloads, or third-party exports---you need a different wrapper that treats every parse as potentially hostile. The next pattern shows how to handle that case safely.
Handling untrusted user YAML
Always use safe_load for user-submitted YAML. Catch and handle the error:
def parse_user_yaml(raw_yaml)
YAML.safe_load(raw_yaml, permitted_classes: [Date])
rescue Psych::DisallowedClass => e
Rails.logger.warn "Blocked disallowed YAML class: #{e.class_name}"
nil
end
The rescue Psych::DisallowedClass block is a safety net, but it only catches the classes you already know to block. Attackers keep finding new deserialization gadgets, so a defense-in-depth approach works better. After you have a basic safe-load wrapper in place, the next step is to inspect the YAML structure before you hand it to the parser at all. That way you can reject suspicious documents before safe_load ever touches them, keeping your security boundary as narrow as possible.
Inspecting YAML before loading
Debug what tags are in a YAML string without executing it:
The advantage of inspecting with YAML.parse is that it reads the document structure without constructing Ruby objects. You get back a Psych::Nodes::Document tree that you can walk safely, checking for suspicious tags like !ruby/object before deciding whether to proceed with safe_load. This two-step approach---inspect first, load second---is especially useful when you accept YAML from third-party APIs or user uploads where the schema is unpredictable. Even with safe_load as your final parser, a pre-pass lets you log unexpected tags and reject payloads before they reach the deserialization step.
require 'yaml'
def yaml_tags(yaml_string)
YAML.parse(yaml_string).children.flat_map do |node|
tag = node.tag&.sub('tag:yaml.org,2002:', '')
[tag] + (node.children.map { |c| c.tag&.sub('tag:yaml.org,2002:', '') }.compact if node.children?)
end.flatten.compact.uniq
end
yaml_tags("--- !ruby/object:Time\nnow: 2023")
# => ["ruby/object", "ruby/object:Time"]
If ruby/object appears in the output and you didn’t expect it, don’t load that YAML.
Related methods
| Method | Safe? | Notes |
|---|---|---|
YAML.load(yaml) | No | Can instantiate any Ruby object. Never use with untrusted input. |
YAML.unsafe_load(yaml) | No | Ruby 3.1+ alias for YAML.load. |
YAML.dump(object) | Yes | Serializes a Ruby object to YAML string. |
YAML.safe_load(yaml) | Yes | Use for everything. |
Psych.safe_load | Yes | Equivalent to YAML.safe_load. |
Forward link
After you understand safe_load, the next step is usually to think about where the parsed data goes next. In many Ruby apps, that means turning the plain hashes and arrays into richer objects such as Structs or passing them through validation before they touch the rest of the application.
See Also
- /reference/modules/yaml/ — The YAML stdlib module reference
- /guides/ruby-struct-guide/ — Struct objects and YAML serialization
- /guides/ruby-pattern-matching/ — Advanced Ruby pattern matching